Webhooks
LutraID can send your backend a signed HTTPS request when a document is issued or a check completes. Webhooks are optional: polling keeps working, and a delivery carries IDs only, so you still fetch the details over the API.
Set up an endpoint
Owners and admins add the endpoint in the dashboard under Developers → Webhooks. Each environment (Sandbox and Production) has one endpoint, so set it up in both.
- Enter an
https://URL. The endpoint receives both events, Document issued and Check completed. - Copy the signing secret. It is shown once. Replace secret issues a new one, and the old one stops signing immediately, so update your receiver first.
- Use Send test event to check your receiver. A test event has the
document.issuedshape, sample IDs and"test": true.
Turning the endpoint off cancels deliveries that are still waiting and skips events until you turn it on again. Missed events are not replayed. Changing the URL applies to deliveries already queued.
The URL must resolve only to public IP addresses. Private, loopback and link-local addresses are refused, also on self-hosted installations, so use a tunnel for a receiver on your laptop.
Events
| Event | Sent when |
|---|---|
document.issued | A wallet received an issued document, from an offer or a wallet-initiated request. |
verification.completed | A check finished with passed or failed. |
Expired offers, expired checks and closed issuance sessions are not sent. Poll for those.
Payload
document.issued
{
"version": 1,
"event_id": "4f0c6a2e-3d4b-4f8e-9a51-0c9f3b2d7e11",
"type": "document.issued",
"occurred_at": "2026-10-19T09:41:00.000Z",
"organization_id": "…",
"operating_environment_id": "…",
"operating_environment_kind": "production",
"data": {
"issuance_profile_id": "…",
"issuance_context_id": "…",
"credential_offer_id": "…",
"issued_artefact_id": "…"
}
}
verification.completed
{
"version": 1,
"event_id": "9b7d1c55-8e2a-4c6f-b0d3-61a4e8f2c905",
"type": "verification.completed",
"occurred_at": "2026-10-19T09:42:00.000Z",
"organization_id": "…",
"operating_environment_id": "…",
"operating_environment_kind": "sandbox",
"data": {
"verification_profile_id": "…",
"verification_id": "…",
"outcome": "passed"
}
}
credential_offer_id is null for wallet-initiated issuance. operating_environment_kind is sandbox or production. Payloads never contain document values, names, email addresses or error details. Use verification_id with the verification result call to read the outcome.
Webhooks are not how check values reach you in Intermediary mode. There, each verifier sends its results, with the shared values, to a result endpoint, and the API returns the outcome only. See Receive check results.
Verify the signature
Every request carries these headers:
| Header | Value |
|---|---|
X-Lutraid-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256> |
Idempotency-Key | The event_id, the same on every retry |
Content-Type | application/json |
Compute HMAC-SHA256 with the signing secret over <t>.<raw request body> and compare it with v1 in constant time. Reject the request if t is more than five minutes from your clock. Use the raw body bytes, not re-serialized JSON.
Verify
import { createHmac, timingSafeEqual } from 'node:crypto'
export function verifyWebhook(secret, header, rawBody) {
const match = /^t=(\d+),v1=([a-f0-9]{64})$/.exec(header ?? '')
if (!match) return false
const [, t, v1] = match
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false
const expected = createHmac('sha256', secret)
.update(`${t}.${rawBody}`)
.digest()
return timingSafeEqual(expected, Buffer.from(v1, 'hex'))
}
Delivery and retries
- Delivery is at least once and in no guaranteed order. Deduplicate by
Idempotency-Key. - Answer with any
2xxwithin 10 seconds. Do the work after you respond. - Network errors, timeouts,
408,429and5xxare retried with exponential backoff, starting at one minute and capped at six hours between attempts, for up to 24 hours. Any other status, including a redirect, fails at once. - Developers → Webhooks → Deliveries shows each delivery and its attempts for 30 days. Retry resends the same event ID and body with the current URL and secret.