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
- Event catalogue
- Payload
- Verify the signature
- Retries
- Replaying a dead delivery
- Requirements for your endpoint
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.aprovedis accepted and then silently never fires — copy names from the table below.
Other management calls:
GET /v1/admin/webhooks | List registered endpoints |
DELETE /v1/admin/webhooks/{id} | Deactivate an endpoint |
GET /v1/admin/webhooks/deliveries | Inspect delivery attempts and failures |
POST /v1/admin/webhooks/deliveries/{id}/replay | Resend one delivery |
Event catalogue
| Event | Fires when | payload |
|---|---|---|
account.created | A chain account is provisioned | accountDid, lifecycle |
kyc.approved | A Person passes verification | personDid, level |
identity.bound | A Person is cryptographically bound to an account | accountDid, personDid, bindingVcHash |
account.unlocked | The account is fully the user's after unlock | accountDid |
aml.sanctions_hit | Screening flags a Person against a sanctions or PEP list | personDid, 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:
| Header | Value |
|---|---|
X-BI-Event | Event name, so you can route before parsing |
X-BI-Timestamp | Unix time in milliseconds, covered by the signature |
X-BI-Signature | Hex HMAC-SHA256 — see below |
X-BI-Delivery-Id | Stable 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
deadand 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
3xxcounts 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
2xxwithin 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
- Authentication — keys, scopes, request signing
- Errors — error codes
- OpenAPI spec — the machine-readable contract