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

New event types and payload fields can appear at any time. Ignore what you do not recognize and acknowledge it with a 2xx response; see Compatibility.

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",
    "matchedItems": [
      {
        "field": "items",
        "index": 1,
        "text": "MLK UHT 3.2% MD 1L x2",
        "sku": "MD-MLK-032-1L",
        "name": "Mleko UHT 3,2% 1 l",
        "category": "Dairy",
        "maker": "Mleczna Dolina",
        "quantity": 2,
        "amount": 9.98,
        "discount": -4.99,
        "netAmount": 4.99,
        "matchedBy": "exact_alias",
        "confidence": 1
      }
    ]
  },
  "failedReason": null,
  "clientSubmissionId": "order-1711353600000",
  "metadata": {
    "orderId": "12345"
  },
  "processedAt": "2026-03-25T10:05:30.000Z"
}

Batch completion uses a smaller aggregate payload:

{
  "event": "batch.completed",
  "batchRef": "summer-import-2026",
  "counts": {
    "total": 10000,
    "processed": 10000,
    "review": 125,
    "failed": 18,
    "approved": 9857
  },
  "completedAt": "2026-07-21T12:00:00.000Z"
}

It is sent to the webhookUrl supplied on the first session in the batch and uses the same signing headers and retry schedule as session webhooks.

How to read the key fields:

  • eventId is the stable event identifier that also appears in X-Webhook-Id
  • initial processing uses evt_<sessionId>_<status>. An explicit REST process-anyway rerun adds _g<generation> (for example, evt_<sessionId>_pending_review_g1 or evt_<sessionId>_failed_g2). Each run has its own event ID, including repeated failures; delivery retries keep that run's ID. Deduplicate on the complete ID. Admin process-anyway does not reopen a failed API job or send a new session webhook.
  • 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: extractedData, confidence, fraudStatus and matchedItems
  • result.matchedItems lists the receipt lines Steve matched to your product catalog, with the catalog SKU Open Loyalty also receives. It is [] when the workflow does not match products, and when matching was skipped. A submission.approved event can list fewer lines than the session.review_required event before it when the product catalog was switched off in between. See Product matching

Steve builds the payload once, when it creates the event. Every retry of one eventId sends the same bytes, so a reviewer edit made between two attempts does not change what a retry delivers. A later change reaches you as a new event with its own eventId.

  • 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
batch.completedEvery known session in a batch is processed and the newest session is at least five minutes old

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}.
  • submission.approved and submission.rejected also fire when a reviewer approves or rejects an API submission in the Steve admin, not only through the API.
  • batch.completed uses the aggregate payload shown above. A pause longer than five minutes during upload can complete the batch before later sessions arrive; later sessions update counts but do not emit another completion event.

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

Retries are counted per event. A receiver that fails one event does not use up the retries of the next one. webhookDeliveryStatus on GET /api/v1/jobs/{sessionId} reports the session's most recent event.

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

For multi-page PDFs, result.pageEvidence may contain { complete, pageCount, blockers, fields }. Fields are keyed by schema name with page (or null), optional per-item pages, flagged, and an optional reason. Raw independent-read values are not included. The same shape is cached in the job result; retries use the event's original immutable body. Treat a flagged field or any document blocker as requiring review.

On this page