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; anything else (or a timeout) triggers the retry schedule.
Your endpoint must meet three requirements:
- Publicly accessible HTTPS URL. Plain HTTP is allowed in sandbox / local testing only.
- 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, deduplicate on the event id, queue the work, and return 2xx. With @stile/node, fromRequest() does the verification in one call — it works with any framework that uses the standard Web API Request object (Next.js, Hono, Cloudflare Workers, Bun, Deno, etc.):
import Stile from "@stile/node";
const stile = new Stile(process.env.STILE_API_KEY!);
export async function POST(req: Request) {
let event;
try {
event = await stile.webhooks.fromRequest(req, process.env.WEBHOOK_SECRET!);
} catch (err) {
console.error("Webhook signature verification failed:", err);
return new Response("Invalid signature", { status: 400 });
}
switch (event.type) {
case "verification_session.verified":
await handleVerified(event.data);
break;
case "verification_session.failed":
await handleFailed(event.data);
break;
case "verification_session.expired":
await handleExpired(event.data);
break;
}
return Response.json({ received: true });
}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 | whd_abc123 | The delivery ID — unique per delivery attempt, including retries. |
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 is a snapshot of the verification session at the moment the event fired:
{
"id": "evt_abc123",
"object": "event",
"type": "verification_session.verified",
"created": 1741564800,
"data": {
"id": "vks_xyz789",
"object": "verification_session",
"status": "verified",
"type": "identity",
"client_reference_id": "user_123",
"expires_at": 1741651200,
"completed_at": 1741564800,
"created": 1741561200
}
}Retries of the same event arrive with the same event id but a new delivery ID — deduplicate on event.id, not on Stile-Webhook-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.
Using fromRequest() (recommended)
The handler in Setup above shows the full pattern: pass the Web API Request plus your secret, and fromRequest() reads the raw body, checks the timestamp, and verifies the HMAC in one call.
Using constructEvent() (low-level)
If your framework doesn't use the standard Request object, use constructEvent() directly with the raw body and signature header:
const event = stile.webhooks.constructEvent(
rawBody, // string or Buffer — the unmodified request body
signatureHeader, // the stile-signature header value
webhookSecret, // your endpoint's signing secret
);Both methods throw WebhookSignatureError on failure — respond 400 in every case:
code | Meaning |
|---|---|
missing_header | No Stile-Signature header on the request. |
invalid_header | Header doesn't match the t=...,v1=... format. |
timestamp_expired | Timestamp is older than the 5-minute tolerance. |
signature_mismatch | HMAC doesn't match — wrong secret or a modified body. |
Raw body is required
JSON body parsers transform the request body before signature verification, which will always
fail. Make sure you pass the raw, unmodified request body to either fromRequest() or
constructEvent().
Not using Node.js?
You don't need an SDK to verify webhooks. See the Webhook Signature Verification guide for the raw algorithm and copy-paste handlers in 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
If your endpoint returns a non-2xx response or doesn't respond within 30 seconds, Stile retries the delivery with exponential backoff:
| 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 retries, your endpoint may receive the same event more than once. Use the event id to deduplicate:
const alreadyProcessed = await db.processedEvents.findUnique({
where: { eventId: event.id },
});
if (alreadyProcessed) {
return Response.json({ received: true }); // Acknowledge but skip
}
await handleEvent(event);
await db.processedEvents.create({ data: { eventId: event.id } });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 copy-paste handlers in 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.