Webhooks
VEREID delivers every domain event over HMAC-signed webhooks with a 5-minute replay window. Schemas, headers, and retry semantics.
Last updated 2026-05-20
Every domain event in VEREID — a verification completing, a session being created, a sanctions hit being escalated, a Stripe subscription cycling — is delivered to your registered webhook URLs as an HTTP POST with a JSON body and an HMAC signature header.
Delivery contract
| Property | Value |
|---|---|
| Method | POST |
| Headers | Content-Type: application/json, vereid-signature: v1,t=...,sig=..., vereid-event-id, vereid-event-type, vereid-delivery-attempt |
| Body | UTF-8 JSON, max 1 MiB |
| Success | Any 2xx within 10 seconds |
| Retries | Up to 11 attempts with exponential backoff (1s, 2s, 4s, …, up to 24h) |
| Idempotency | Same vereid-event-id will be re-delivered on retry — treat your handler as idempotent |
| TLS | Required; we verify your certificate against the public CA bundle |
Signature header
The vereid-signature header carries a versioned scheme so we can rotate the algorithm without breaking subscribers.
vereid-signature: v1,t=1716220800,sig=8a7df40c2b51ee9c12b58aa8e9eb1d1a8b09f4eecb8d3a0b9b6f5e1f3c7d8e9a| Component | Meaning |
|---|---|
v1 | Scheme version. Currently the only version. |
t=<unix> | When the request was signed (seconds). |
sig=<hex> | Lowercase hex of HMAC-SHA256(secret, "<t>.<raw body>"). |
The signed payload is the literal concatenation t + "." + body where body is the raw request body bytes — not a re-serialised JSON. If you parse and re-encode the body before verifying, your signature check will fail.
Replay window
A request is considered fresh if |now - t| ≤ 300 seconds (5 minutes). Outside that window, reject the request even if the signature is valid. This bounds the window in which a captured webhook can be re-played by an attacker who later obtained your secret.
For higher-stakes endpoints (verification approvals, billing changes), keep an at-least-24h log of seen vereid-event-id values and short-circuit re-deliveries.
Verifying — code samples
Node.js / TypeScript
import crypto from "node:crypto";
export function verify(rawBody, header, secret, toleranceSec = 300) {
const m = /^v1,t=(\d+),sig=([a-f0-9]+)$/.exec(header ?? "");
if (!m) return false;
const [, t, sig] = m;
if (Math.abs(Math.floor(Date.now() / 1000) - Number(t)) > toleranceSec)
return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
return (
Buffer.byteLength(sig) === Buffer.byteLength(expected) &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
);
}Python
import hmac, hashlib, time, re
def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
m = re.fullmatch(r"v1,t=(\d+),sig=([a-f0-9]+)", header or "")
if not m:
return False
t, sig = m.group(1), m.group(2)
if abs(int(time.time()) - int(t)) > tolerance:
return False
expected = hmac.new(
secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(sig, expected)Go
func Verify(raw []byte, header, secret string, tolerance time.Duration) bool {
re := regexp.MustCompile(`^v1,t=(\d+),sig=([a-f0-9]+)$`)
m := re.FindStringSubmatch(header)
if m == nil { return false }
t, _ := strconv.ParseInt(m[1], 10, 64)
if d := time.Since(time.Unix(t, 0)); d > tolerance || d < -tolerance { return false }
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(strconv.FormatInt(t, 10) + "."))
mac.Write(raw)
return hmac.Equal([]byte(m[2]), []byte(hex.EncodeToString(mac.Sum(nil))))
}curl
You typically test webhook delivery with the vereid CLI:
vereid webhooks tail --endpoint=we_01HZ... --replay=evt_01HZ...Event envelope
Every event uses the same envelope. Type-specific payload lives under data.
{
"id": "evt_01HZ3X4P9KH8E7F2C5RB1Y0WMA",
"type": "verification.completed",
"api_version": "2026-05-20",
"created": 1716220800,
"livemode": false,
"data": {
"id": "vs_01HZ3K8R...",
"reference": "user_42",
"tier": "T2",
"badges": ["photo", "liveness"],
"confidence": 0.94
}
}Subscribable event types
| Type | When | Common consumers |
|---|---|---|
verification.completed | Hosted verify finished, decision recorded | CRM, fraud, KYC review |
verification.failed | Decision is declined or error | Support, fraud |
sanctions.hit.flagged | T3 produced a non-clear hit | Compliance |
user.created | Tenant user signed up | CRM |
oauth.token.issued | Token issued for a tenant app | SIEM, audit |
oauth.client.created | Self-service dev created an app | Notifications |
post.created, follow.created | Social events | Activity feeds |
subscription.updated, invoice.paid | Stripe-backed billing events | Finance |
Failure handling
If your endpoint returns a non-2xx, we retry with exponential backoff: 1s, 2s, 4s, 8s, 16s, 32s, 1m, 5m, 30m, 6h, 24h. After 11 attempts the delivery is parked and surfaced in the developer console; you can replay any parked delivery from the UI or via vereid webhooks replay <id>.
5xx responses retry indefinitely (up to 11 attempts). 4xx responses retry the same way unless they are 410 Gone or 404 Not Found, which we interpret as "endpoint permanently dead" and disable the subscription after surfacing a notification.