VVEREID Docs
concepts

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

PropertyValue
MethodPOST
HeadersContent-Type: application/json, vereid-signature: v1,t=...,sig=..., vereid-event-id, vereid-event-type, vereid-delivery-attempt
BodyUTF-8 JSON, max 1 MiB
SuccessAny 2xx within 10 seconds
RetriesUp to 11 attempts with exponential backoff (1s, 2s, 4s, …, up to 24h)
IdempotencySame vereid-event-id will be re-delivered on retry — treat your handler as idempotent
TLSRequired; 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
ComponentMeaning
v1Scheme 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

TypeWhenCommon consumers
verification.completedHosted verify finished, decision recordedCRM, fraud, KYC review
verification.failedDecision is declined or errorSupport, fraud
sanctions.hit.flaggedT3 produced a non-clear hitCompliance
user.createdTenant user signed upCRM
oauth.token.issuedToken issued for a tenant appSIEM, audit
oauth.client.createdSelf-service dev created an appNotifications
post.created, follow.createdSocial eventsActivity feeds
subscription.updated, invoice.paidStripe-backed billing eventsFinance

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.