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
| Event | Fires when | data.object |
|---|---|---|
ocr.completed | A POST /v1/ocr request returns 2xx | The 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.completed | A POST /v1/face/verify request returns 2xx | The verify response: match, similarity, threshold, image_a, image_b, model_version, request_id. |
liveness.check.completed | A POST /v1/liveness/check request returns 2xx | The 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
tat 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:
| Attempt | Delay from the previous |
|---|---|
| 1 | immediate |
| 2 | 1 second |
| 3 | 1 minute |
| 4 | 1 hour |
| 5 | 24 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:
GET /v1/ocr/{ocr_id}GET /v1/liveness/checks/{lc_id}GET /v1/fraud/checks/{fc_id}GET /v1/verifications/{vf_id}- Face verify has no read endpoint; the response is fully delivered on the original POST.
Reads are not metered; only the original call bills.