SteveSteve

Webhooks

Delivery contract, signature verification, retries, and testing for Steve webhooks.

Steve uses webhooks for asynchronous session and submission updates.

There are two related pieces:

  1. POST /api/v1/webhooks/test for reachability checks
  2. production deliveries for sessions created with a webhookUrl

Connectivity test

Use the test endpoint before turning on production deliveries:

curl "$BASE_URL/api/v1/webhooks/test" \
  -X POST \
  -H "Authorization: Bearer $STEVE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"webhookUrl":"https://integrator.example.com/webhooks/steve"}'

Steve performs a direct POST with this payload:

{
  "type": "webhook.test",
  "timestamp": "2026-03-25T10:00:00.000Z"
}

Test-delivery behavior:

  • timeout: 5 seconds
  • content type: application/json
  • user agent: steve-webhook/1.0
  • signature header: not present on test requests

Production delivery headers

Production deliveries include:

HeaderMeaning
Content-Type: application/jsonJSON payload
X-Webhook-SignatureHMAC-SHA256 signature
X-Webhook-EventEvent type string
X-Webhook-IdStable delivery id across retries
X-Webhook-TimestampUnix seconds when the attempt was sent
User-Agent: steve-webhook/1.0Sender identifier

Production payload shape

Steve sends one generic envelope for both session-completion and later submission-action events:

{
  "eventId": "jx7evt123submissionEvents",
  "eventType": "session.review_required",
  "occurredAt": "2026-03-25T10:05:30.000Z",
  "sessionId": "jx7ses789session",
  "submissionId": "jx7sub456submissions",
  "workflow": {
    "slug": "receipt-ocr",
    "version": 3
  },
  "state": "completed",
  "submissionStatus": "review",
  "companyId": "jx7abc123company",
  "result": {
    "extractedData": {
      "merchantName": "Biedronka",
      "totalAmount": 123.45,
      "currency": "PLN"
    },
    "confidence": 0.96,
    "fraudStatus": "flagged"
  },
  "failedReason": null,
  "clientSubmissionId": "order-1711353600000",
  "metadata": {
    "orderId": "12345"
  },
  "processedAt": "2026-03-25T10:05:30.000Z"
}

How to read the key fields:

  • eventId is the stable event identifier that also appears in X-Webhook-Id
  • eventType is the primary routing field
  • occurredAt is the business timestamp for the event itself
  • state is the session lifecycle state
  • submissionStatus is the public business outcome when Steve can determine it
  • result is the workflow-specific processing payload
  • clientSubmissionId and metadata are echoed from session creation

Event types

Steve currently emits these event types:

Event typeFired when
session.completedProcessing finished and no review is required
session.failedProcessing failed
session.review_requiredProcessing finished and a reviewer must decide the outcome
submission.approvedA submission was approved
submission.rejectedA submission was rejected
submission.cancelledA submission was cancelled
submission.fraud_match_resolvedA fraud match was resolved

Two important nuances:

  • Session lifecycle routing belongs to eventType, not to a separate internal status field.
  • Use submissionStatus to distinguish review from approved, synced, rejected, cancelled, or failed.
  • submission action events reuse the same envelope and stay intentionally small. If you need the latest reasons, fraud state, or file links, refetch GET /api/v1/submissions/{submissionId}.

Signature verification

Production deliveries include:

X-Webhook-Signature: sha256=<hex>

Steve signs the raw request body using HMAC-SHA256 with this secret:

SHA-256(apiKey) rendered as lowercase hex

That means your receiver must first hash the API key to a hex string and then use that hex string as the HMAC key. The private TypeScript SDK exports deriveWebhookSecret, signWebhookPayload, and verifyWebhookSignature. When the SDK is available, pass deriveWebhookSecret(apiKey)'s result as the secret argument to verifyWebhookSignature.

import crypto from "node:crypto";
 
export function verifyWebhook(rawBody, signatureHeader, apiKey) {
  if (
    typeof signatureHeader !== "string" ||
    !signatureHeader.startsWith("sha256=")
  ) {
    return false;
  }
 
  const secret = crypto.createHash("sha256").update(apiKey).digest("hex");
  const expected =
    "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
  const expectedBuffer = Buffer.from(expected, "utf8");
  const signatureBuffer = Buffer.from(signatureHeader, "utf8");
 
  return (
    expectedBuffer.length === signatureBuffer.length &&
    crypto.timingSafeEqual(expectedBuffer, signatureBuffer)
  );
}

Deduplication and replay protection

Use the headers together:

  • store X-Webhook-Id to deduplicate retries
  • reject stale deliveries using X-Webhook-Timestamp

Typical replay window:

const sentAt = Number(req.headers["x-webhook-timestamp"]);
const now = Math.floor(Date.now() / 1000);
 
if (Math.abs(now - sentAt) > 300) {
  throw new Error("stale webhook");
}

Retry behavior

Steve gives each delivery attempt 10 seconds to complete. If the target does not return 2xx in time:

  1. the initial attempt fails
  2. retry 1 is scheduled 30 seconds later
  3. retry 2 is scheduled 5 minutes later
  4. retry 3 is scheduled 30 minutes later
  5. after the third retry fails, delivery status becomes failed

Make your handler idempotent and return quickly.

Webhook URL validation

Steve applies syntactic validation to require HTTPS webhook targets and reject known local destinations. It rejects URLs that:

  • are not valid absolute URLs
  • do not use https://
  • target localhost or 127.0.0.1
  • target RFC1918 private ranges
  • end in .internal or .local

Steve does not resolve DNS during validation. A public-looking hostname can therefore pass this check even if it later resolves to a private address.

  1. verify X-Webhook-Signature
  2. validate X-Webhook-Timestamp
  3. deduplicate on X-Webhook-Id
  4. route on eventType
  5. use state and submissionStatus for lightweight routing, then refetch the submission or job if you need the latest full state
  6. return 200 as soon as the event is durably recorded

On this page