← Documentation

Webhooks

Event catalogue, signature verification, retry policy, and replaying a failed delivery.

Identity Inc. posts events to your endpoint as they happen, so you do not have to poll for KYC approvals or bind completions.

Register an endpoint

Needs a key with the webhook_manage scope.

curl -X POST https://api.identityinc.io/v1/admin/webhooks \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "url": "https://example.com/hooks/identity",
        "events": ["kyc.approved", "identity.bound"]
      }'

The response contains the signing secret, shown once. Store it before you close the terminal; there is no way to read it back. You can also register from the console under Settings → Outbound webhooks.

Pass ["*"] to receive every event, including ones added later.

Event names are not validated at registration. A typo like kyc.aproved is accepted and then silently never fires — copy names from the table below.

Other management calls:

GET /v1/admin/webhooksList registered endpoints
DELETE /v1/admin/webhooks/{id}Deactivate an endpoint
GET /v1/admin/webhooks/deliveriesInspect delivery attempts and failures
POST /v1/admin/webhooks/deliveries/{id}/replayResend one delivery

Event catalogue

EventFires whenpayload
account.createdA chain account is provisionedaccountDid, lifecycle
kyc.approvedA Person passes verificationpersonDid, level
identity.boundA Person is cryptographically bound to an accountaccountDid, personDid, bindingVcHash
account.unlockedThe account is fully the user's after unlockaccountDid
aml.sanctions_hitScreening flags a Person against a sanctions or PEP listpersonDid, severity

kyc.approved is the one most integrations need: it is the signal that a verification you started has finished, and it saves polling GET /v1/kyc/status.

aml.sanctions_hit is a compliance signal, not a lifecycle one. Route it to whoever handles reviews rather than to your signup code path.

Payload

The body is a JSON envelope; the event-specific fields are nested under payload.

{
  "event": "kyc.approved",
  "tenantId": "tnt_...",
  "payload": { "personDid": "did:bfid:person:...", "level": "basic" },
  "createdAt": "2026-08-06T10:31:00.000Z"
}

Headers on every delivery:

HeaderValue
X-BI-EventEvent name, so you can route before parsing
X-BI-TimestampUnix time in milliseconds, covered by the signature
X-BI-SignatureHex HMAC-SHA256 — see below
X-BI-Delivery-IdStable ID for this delivery; use it to deduplicate

Verify the signature

Compute HMAC-SHA256 over the timestamp and the raw request body, joined by a dot:

signature = hex(HMAC-SHA256(secret, `${X-BI-Timestamp}.${rawBody}`))

This is not the canonical string used for outbound request signing, which also covers the method, path, and a body hash. Same header names, different payload — verifying webhooks with the request-signing helper will fail every time. See Authentication.

Verify against the exact bytes you received. Parsing to JSON and re-serializing changes key order and whitespace, and the signature will not match:

import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";

const app = express();

// Raw body, not express.json() — the signature covers the bytes on the wire.
app.post("/hooks/identity", express.raw({ type: "application/json" }), (req, res) => {
  const ts = req.header("X-BI-Timestamp") ?? "";
  const sig = req.header("X-BI-Signature") ?? "";
  const raw = req.body.toString("utf8");

  const expected = createHmac("sha256", process.env.BI_WEBHOOK_SECRET!)
    .update(`${ts}.${raw}`)
    .digest("hex");

  // Constant-time compare; a length mismatch is already a rejection.
  const ok =
    expected.length === sig.length &&
    timingSafeEqual(Buffer.from(expected), Buffer.from(sig));
  if (!ok) return res.sendStatus(401);

  // Reject stale timestamps so a captured delivery cannot be replayed at will.
  if (Math.abs(Date.now() - Number(ts)) > 5 * 60_000) return res.sendStatus(401);

  const { event, payload } = JSON.parse(raw);
  enqueueForProcessing(req.header("X-BI-Delivery-Id"), event, payload); // 2xx fast
  res.sendStatus(200);
});

Retries

A delivery is retried until it succeeds or runs out of attempts:

  • 8 attempts maximum, then the delivery is marked dead and not retried.
  • Backoff doubles each attempt — roughly 2s, 4s, 8s … capped at 1 hour.
  • Each attempt times out after 10 seconds.
  • Any non-2xx response counts as a failure.
  • Redirects are not followed. A 3xx counts as a failure — serve on the registered URL directly.

Treat delivery as at-least-once and deduplicate on X-BI-Delivery-Id — a receiver that times out after doing its work may see the same delivery again. Dead deliveries can still be resent later (see below).

Replaying a dead delivery

Once your endpoint is fixed, resend without waiting for a new event:

curl -X POST https://api.identityinc.io/v1/admin/webhooks/deliveries/$DELIVERY_ID/replay \
  -H "Authorization: Bearer $API_KEY"

The attempt counter resets — the eight attempts already spent were against a broken receiver, and holding them against the retry would give the fixed endpoint a single chance.

Find candidates with GET /v1/admin/webhooks/deliveries, which reports status, attempts, and lastError per delivery.

Requirements for your endpoint

  • Public HTTPS URL. Private and link-local addresses are rejected.
  • Respond 2xx within 10 seconds. Acknowledge first and process asynchronously — slow handlers turn into retries and duplicates.
  • Be idempotent, keyed on X-BI-Delivery-Id.
  • Verify the signature before trusting anything in the body.

See also

Webhooks · Identity Inc.