Skip to main content

Webhooks

The platform delivers async events to HTTPS endpoints you register, so your backend can react to completed checks without polling. Configure endpoints from the Dashboard → Webhooks page. This page covers the protocol: what your endpoint receives and what it must do.

Event types​

EventFires whendata.object
ocr.completedA POST /v1/ocr request returns 2xxThe scan response: ocr_id, document_type, ocr_eligible, eligibility_reason, gate_rejections, fields, raw_text, portrait, customer_ref, mode, request_id. See Document scanning.
face.verify.completedA POST /v1/face/verify request returns 2xxThe verify response: match, similarity, threshold, image_a, image_b, model_version, request_id.
liveness.check.completedA POST /v1/liveness/check request returns 2xxThe check response (lc_id, is_live, score, threshold, reason, mode, request_id, …) plus customer_ref. See Liveness checks.

These three are the event types an endpoint can subscribe to today. The gateway also publishes fraud.signal_detected, face.registration_verify.completed and verification.completed, but they cannot yet be selected on an endpoint, so they are not delivered; poll the corresponding GET endpoint for those results.

Events are published for both live and test mode. An endpoint is registered for one mode and receives only that mode's events; livemode in the envelope says which.

Delivery format​

The envelope follows the Stripe event shape: the result is nested under data.object, the timestamp is a Unix created, and the signature travels in a Stripe-Signature header so existing verification libraries work unchanged.

POST https://your-endpoint
Content-Type: application/json
Stripe-Signature: t=1778632003,v1=<hex-hmac>

{
"id": "evt_01HF...",
"object": "event",
"api_version": "2026-05-12",
"created": 1778632003,
"livemode": true,
"type": "ocr.completed",
"data": {
"object": { ...the API response for that call... }
}
}

The Test fire button on an endpoint's dashboard page sends the same envelope with an evt_test_… id and whatever type you pick, signed with the endpoint's secret, so you can exercise your handler before real traffic.

Signature verification​

Every endpoint gets its own signing secret (whsec_…), shown exactly once at creation and again after a rotation. Each delivery is signed with HMAC-SHA256 over {t}.{rawBody} using that secret, and the header carries the timestamp and one or two v1= values:

Stripe-Signature: t=1778632003,v1=<hmac with current secret>[,v1=<hmac with previous secret>]

For 48 hours after a rotation the header carries a second v1= computed with the previous secret, so you can roll the new secret out without a gap. Accept the delivery if any v1= value matches your computation. Verify server-side before trusting the body.

Node.js

const crypto = require('crypto');

function verify(req, secret) {
const header = req.headers['stripe-signature'] ?? '';
const parts = Object.create(null);
const sigs = [];
for (const kv of header.split(',')) {
const [k, v] = kv.split('=');
if (k === 'v1') sigs.push(v);
else parts[k] = v;
}
const expected = crypto
.createHmac('sha256', secret)
.update(`${parts.t}.${req.rawBody}`)
.digest('hex');
const ok = sigs.some((sig) =>
sig.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sig, 'hex'), Buffer.from(expected, 'hex')),
);
if (!ok) throw new Error('signature mismatch');
// Reject replays older than 5 minutes
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) {
throw new Error('stale timestamp');
}
}

Go

import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"fmt"
"strconv"
"strings"
"time"
)

func Verify(header string, body []byte, secret string) error {
var ts string
var sigs []string
for _, kv := range strings.Split(header, ",") {
k, v, _ := strings.Cut(kv, "=")
switch k {
case "t":
ts = v
case "v1":
sigs = append(sigs, v)
}
}
mac := hmac.New(sha256.New, []byte(secret))
fmt.Fprintf(mac, "%s.%s", ts, body)
expected := hex.EncodeToString(mac.Sum(nil))
ok := false
for _, sig := range sigs {
if subtle.ConstantTimeCompare([]byte(sig), []byte(expected)) == 1 {
ok = true
}
}
if !ok {
return fmt.Errorf("signature mismatch")
}
tsNum, _ := strconv.ParseInt(ts, 10, 64)
if d := time.Since(time.Unix(tsNum, 0)); d > 5*time.Minute || d < -5*time.Minute {
return fmt.Errorf("stale timestamp")
}
return nil
}

Two non-obvious things both languages share:

  • Use the raw request body. Anything that re-serializes JSON reorders keys and breaks the signature. Capture the raw bytes before parsing.
  • Reject replays older than 5 minutes. A legitimate retry is signed with a fresh t at send time, so anything older is a replayed payload.

Retry policy​

A delivery succeeds when your endpoint returns any 2xx status within 30 seconds. Anything else, including a timeout or a connection error, is a failure, and the dispatcher retries on a fixed schedule:

AttemptDelay from the previous
1immediate
21 second
31 minute
41 hour
524 hours

After the fifth failure the delivery is marked dead and no further attempts are made; the endpoint's dashboard page lists every attempt with its response. Endpoints are not disabled automatically; a failing one keeps receiving new events on the same schedule until you disable or fix it.

Idempotency on your end​

Webhook delivery is at-least-once. The same evt_… id can land at your endpoint more than once (a 200 the platform never received, a network blip mid-acknowledgement). Make your handler idempotent:

async function handleWebhook(event) {
// De-dupe by the event id. If we've already processed this id, skip.
const seen = await redis.set(`wh:${event.id}`, '1', 'EX', 86400 * 2, 'NX');
if (!seen) {
return; // already handled this exact delivery
}
// ...do real work...
}

A two-day TTL covers the retry tail with room to spare.

Endpoint requirements​

  • HTTPS only. http:// URLs are rejected at registration time, and so are URLs that resolve to a private network address.
  • Respond within 30 seconds. Do not do real work in the handler; accept the event, enqueue it, return 200.
  • Return 200 even if you intentionally skip the event. Returning 4xx triggers the retry schedule.
  • One mode per endpoint. Register the same URL twice, once per mode, to receive both streams.

Polling fallback​

Every event has a corresponding read endpoint you can call later with the id from the original API call:

Reads are not metered; only the original call bills.