stile
Guides

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 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, 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.):

app/api/webhooks/route.ts
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):

HeaderExampleDescription
Stile-Signaturet=1741564800,v1=abc123...Timestamp + HMAC-SHA256 signature. Verify before processing.
Stile-Webhook-Idwhd_abc123The delivery ID — unique per delivery attempt, including retries.
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 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.

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:

codeMeaning
missing_headerNo Stile-Signature header on the request.
invalid_headerHeader doesn't match the t=...,v1=... format.
timestamp_expiredTimestamp is older than the 5-minute tolerance.
signature_mismatchHMAC 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:

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 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/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