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 official TypeScript SDK in packages/steve-sdk includes matching helpers.

import crypto from "node:crypto";
 
export function verifyWebhook(rawBody, signatureHeader, apiKey) {
  const secret = crypto.createHash("sha256").update(apiKey).digest("hex");
  const expected =
    "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
 
  return crypto.timingSafeEqual(
    Buffer.from(expected, "utf8"),
    Buffer.from(signatureHeader, "utf8"),
  );
}

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 accepts only public HTTPS webhook targets. 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
  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