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
2xxwithin 30 seconds. Slower responses count as failures. - Live orgs require at least one active webhook endpoint before sessions can be created —
POST /v1/verification_sessionsreturns400 webhook_requiredotherwise. 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.

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):
| Header | Example | Description |
|---|---|---|
Stile-Signature | t=1741564800,v1=abc123... | Timestamp + HMAC-SHA256 signature. Verify before processing. |
Stile-Webhook-Id | cm5x7k9h20000qwerty123456 | The opaque delivery record ID. Automatic retries of that delivery reuse it. |
User-Agent | Stile/1.0 | Identifies Stile's webhook dispatcher. |
Content-Type | application/json | The 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.
| Attempt | Delay after previous failure |
|---|---|
| 1 (initial) | Immediate |
| 2 | 5 minutes |
| 3 | 30 minutes |
| 4 | 2 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/webhooksRegister 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
Webhook Signature Verification
The raw HMAC algorithm plus standard-library examples in Node.js, Python, Go, Ruby, and PHP.
Webhook Endpoints API
Create, update, delete, and rotate secrets for endpoints; inspect and retry deliveries.
Events API
List and retrieve event objects, and browse the full event-type catalog.
Testing
Sandbox mode, skip_verification, and exercising every lifecycle event end to end.