Docs

Webhooks

Receive real-time notifications when verification events occur — endpoint setup, signature verification, retries, deduplication, and local testing.

Webhooks are how Stile tells your server that something happened — a session verified, a manual review resolved, a trust-reuse grant revoked — without you polling for it. This guide is the canonical reference for receiving webhooks: registering an endpoint, verifying signatures, handling retries and duplicates, and testing locally.

How delivery works

When an event occurs, Stile sends an HTTP POST with a signed JSON payload to every endpoint subscribed to that event type. Your endpoint acknowledges with a 2xx response. A 429, 5xx, network failure, or timeout triggers the retry schedule; other 4xx responses are treated as permanent endpoint errors.

Your endpoint must meet three requirements:

  • Publicly accessible HTTPS URL. Use an HTTPS tunnel when the handler runs locally.
  • Respond with 2xx within 30 seconds. Slower responses count as failures.
  • Live orgs require at least one active webhook endpoint before sessions can be created — POST /v1/verification_sessions returns 400 webhook_required otherwise. Sandbox orgs are exempt.

Beyond the session lifecycle (verification_session.*), events also fire for manual review outcomes (session_review.*) and trust-reuse grants and consent revocations (trust_reuse_grant.*, trust_reuse_consent.*) — see the full event-type catalog.

Setup

Register an endpoint

Register a webhook endpoint in the dashboard or via the API. Subscribe to the event types you handle, or use ["*"] to receive everything.

curl -X POST https://api.stile.id/v1/webhook_endpoints \
  -H "Authorization: Bearer stile_sk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://yourapp.com/api/webhooks",
    "enabled_events": ["verification_session.verified", "verification_session.failed"]
  }'
const endpoint = await stile.webhookEndpoints.create({
  url: "https://yourapp.com/api/webhooks",
  enabled_events: ["verification_session.verified", "verification_session.failed"],
});

Store the signing secret

The create response includes a secret — it's only shown once. Save it to your environment variables (e.g. WEBHOOK_SECRET); you'll need it to verify every delivery. If a secret is ever exposed, rotate it — the new secret is likewise returned exactly once.

Implement your handler

Verify the signature against the raw body, deduplicate on the event id, queue or persist the work, and return 2xx. Use the complete standard-library handler for Node.js, Python, Go, Ruby, or PHP. No Stile package is required.

Watch deliveries

Every delivery is recorded. View delivery history in the dashboard under Webhooks > Deliveries, retrieve it via the deliveries API, or retry a failed delivery manually. Log the Stile-Webhook-Id header from each request to correlate what your server received with the delivery record.

Synthetic dashboard example showing a successful verification webhook delivery, its endpoint, signed event type, response body, and timing

Payload and headers

Every delivery is an HTTP POST carrying a JSON event object and these headers (HTTP header names are case-insensitive; most frameworks expose them lowercase, e.g. stile-signature):

HeaderExampleDescription
Stile-Signaturet=1741564800,v1=abc123...Timestamp + HMAC-SHA256 signature. Verify before processing.
Stile-Webhook-Idcm5x7k9h20000qwerty123456The opaque delivery record ID. Automatic retries of that delivery reuse it.
User-AgentStile/1.0Identifies Stile's webhook dispatcher.
Content-Typeapplication/jsonThe body is always a JSON event object.

The body is an event object whose data.object is a snapshot of the resource at the moment the event fired. For verification_session.* events, it is the verification session:

{
  "id": "evt_abc123",
  "object": "event",
  "type": "verification_session.verified",
  "created": 1741564800,
  "data": {
    "object": {
      "id": "vks_xyz789",
      "object": "verification_session",
      "status": "verified",
      "type": "identity",
      "client_reference_id": "order_123",
      "current_method": "document_capture",
      "age_tier": "min_age_21",
      "jurisdiction": "US-CA",
      "jurisdiction_audit_declared": "US-CA",
      "jurisdiction_audit_ip_derived": "US-CA",
      "jurisdiction_audit_resolved": "US-CA",
      "jurisdiction_audit_source": "input_field",
      "jurisdiction_audit_mismatch": false,
      "verification_path": null,
      "workflow_version_id": "wfv_abc123",
      "livemode": true,
      "completed_at": 1741564800,
      "created": 1741561200,
      "verification_result": {
        "method": "document_capture",
        "confidence": 0.98,
        "age_verified": true,
        "age_estimate": 29,
        "identity_verified": true,
        "face_match_passed": true,
        "barcode_cross_ref_match": true,
        "liveness_score": 0.97
      }
    }
  }
}

The webhook snapshot is intentionally smaller than the object returned by GET /v1/verification_sessions/:id: it does not include client_secret, metadata, collected data, expires_at, URLs, or the full results array. verification_result is either null, the document-capture summary shown above, or an NFC-passport summary with chip_passive_auth_passed and chip_active_auth_passed in place of the two document-only fields. The legacy livemode compatibility field is false for a sandbox organization and true otherwise. Retrieve the session from your server if you need its current full state.

Automatic and manual retries arrive with the same event id and Stile-Webhook-Id. Delivery to another subscribed endpoint has a different delivery ID for the same event. Always deduplicate business work on event.id.

Signature verification

The Stile-Signature header has the format t={timestamp},v1={signature}, where the signature is an HMAC-SHA256 over {timestamp}.{raw_body} keyed with your endpoint secret. Always verify it before processing the event; signatures with a timestamp older than 5 minutes are rejected to prevent replay.

Use your platform's standard HMAC-SHA256 and constant-time comparison functions. The Webhook Signature Verification guide provides signature-verification examples for Node.js, Python, Go, Ruby, and PHP with no Stile package dependency.

Respond 400 when the signature header is missing or malformed, the timestamp is outside the five-minute window, or the HMAC does not match. Parse and process the event only after all checks pass.

Raw body is required

JSON body parsers transform the request body before signature verification, which will always fail. Read the raw, unmodified request body before your framework's JSON parser runs.

Works with every backend

You don't need an SDK to verify webhooks. See the Webhook Signature Verification guide for standard-library examples in Node.js, Python, Go, Ruby, and PHP.

Best practices

Return a 2xx as soon as you've queued the work

Verify the signature, enqueue, acknowledge. Database writes, emails, and fulfillment belong after the acknowledgment (or on a job queue) — a handler that does heavy work inline will hit the 30-second timeout under load and turn healthy events into retries.

Treat webhook delivery as the source of truth

Webhooks are your primary integration path for verification outcomes. Poll GET /v1/verification_sessions/:id only on cold start or for reconciliation — not as your main way of learning that a session finished.

Retry behavior

Stile retries when your endpoint returns 429 or 5xx, fails at the network layer, or does not respond within 30 seconds. Other 4xx responses are marked permanently failed without automatic retries because they indicate that the endpoint must be corrected first.

Webhook retry timeline: attempt 1 is immediate, then attempts follow after 5 minutes, 30 minutes, 2 hours, and 8 hours, for five attempts total

AttemptDelay after previous failure
1 (initial)Immediate
25 minutes
330 minutes
42 hours
5 (final)8 hours

After the final attempt (roughly 10.5 hours from the initial delivery), the delivery is marked as permanently failed. Failed deliveries stay visible in the dashboard and the deliveries API, and can be retried manually once your endpoint is healthy again.

Handling duplicates

Due to automatic or manual retries—or multiple subscribed endpoints—your application may receive the same event more than once. Claim the event ID and apply the business update in one database transaction:

await db.$transaction(async (tx) => {
  const claim = await tx.processedWebhookEvent.createMany({
    data: [{ eventId: event.id }],
    skipDuplicates: true, // eventId has a unique constraint
  });

  if (claim.count === 0) return; // Already applied; acknowledge it again.

  await handleEvent(tx, event);
});

If the transaction fails, return a 5xx so Stile retries. If the event was already claimed, return 2xx without repeating the update.

Local testing

Use a tunnel tool to expose your local server during development:

# Using ngrok
ngrok http 3000

# Your webhook URL becomes something like:
# https://abc123.ngrok-free.app/api/webhooks
# Using Cloudflare Tunnel (quick tunnel, no account required)
cloudflared tunnel --url http://localhost:3000

# Your webhook URL becomes something like:
# https://<random-subdomain>.trycloudflare.com/api/webhooks

Register the tunnel URL as a webhook endpoint in the dashboard, then trigger test events by creating verification sessions with your stile_sk_ key. In a sandbox org, skip_verification: true produces an instantly verified session that fires real webhooks — see the Testing guide.

Next steps

On this page