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.

  1. Enter an https:// URL. The endpoint receives both events, Document issued and Check completed.
  2. 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.
  3. Use Send test event to check your receiver. A test event has the document.issued shape, 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

EventSent when
document.issuedA wallet received an issued document, from an offer or a wallet-initiated request.
verification.completedA 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.

Verify the signature

Every request carries these headers:

HeaderValue
X-Lutraid-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256>
Idempotency-KeyThe event_id, the same on every retry
Content-Typeapplication/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 2xx within 10 seconds. Do the work after you respond.
  • Network errors, timeouts, 408, 429 and 5xx are 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.

Was this page helpful?