# Webhooks

> Every status change is POSTed as JSON and signed. Reply 2xx within 10 seconds. Use the event id to ignore retries.

Canonical page: https://venuego.co.uk/docs/webhooks

Add HTTPS endpoints in Developers, or pass `webhookUrl` on a single session. Endpoint events are signed with that endpoint's secret (`whsec_…`), shown once when you add it. A per-session `webhookUrl` is signed with the account's per-session secret, which you can reveal and rotate in Developers. A rotation applies to the next send, including retries.

The delivery is stored before it is queued. A lost queue message delays the event. It does not drop it.

## Request

```http
POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: VerifyMe-Webhooks/1.0
X-VerifyMe-Signature: t=1710000000,v1=hex…
X-VerifyMe-Delivery: 01J…
X-VerifyMe-Attempt: 1
```

`X-VerifyMe-Signature` is `t=<unix seconds>,v1=<hex hmac-sha256 of "t.body">`. The body is the raw request bytes, not a re-serialised object. Compare the hex with a constant-time check, and reject timestamps more than 5 minutes from now.

```js
import { createHmac, timingSafeEqual } from "node:crypto";

function verify(rawBody, header, secret) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
  const expected = createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(parts.v1 ?? "");
  return a.length === b.length && timingSafeEqual(a, b);
}
```

```python
import hashlib, hmac, time

def verify(raw_body: bytes, header: str, secret: str) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    if abs(time.time() - int(parts["t"])) > 300:
        return False
    expected = hmac.new(secret.encode(), f"{parts['t']}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))
```

## Body

```json
{
  "id": "01JEVENT…",
  "type": "verification.approved",
  "createdAt": "2026-10-07T16:04:11.000Z",
  "data": {}
}
```

`data` is the full verification payload from [Sessions](https://venuego.co.uk/docs/sessions/agent.md), including `decision`, `identity`, `checks`, and your `metadata`. The event `id` stays the same across retries. `X-VerifyMe-Delivery` is the delivery row and changes if you resend from the portal.

## Events

| Type | When |
| --- | --- |
| `verification.created` | The session was created. |
| `verification.started` | The guest opened the link. |
| `verification.face_captured` | The face capture was uploaded. |
| `verification.document_captured` | The document photos were uploaded. |
| `verification.processing` | Automated checks started. |
| `verification.retry_started` | The guest started another attempt after a retryable decline. |
| `verification.needs_review` | Nothing declined, but a person needs to decide. Status stays `needs_review` until then. |
| `verification.approved` | Approved, by the checks or by a person. `decision.source` says which. |
| `verification.declined` | Declined, by the checks or by a person. `decision.retryableStep` is set when the guest can try again. |
| `verification.decision_changed` | A person changed the outcome, including after it was final. Sent as well as `approved` or `declined`. If you granted access on an earlier approval, revoke it when a later event declines. |
| `verification.cancelled` | You cancelled the session. |
| `verification.expired` | The guest did not finish within 24 hours. |

## Retries

Reply with any `2xx` within 10 seconds. Redirects are not followed. Anything else is retried after 1, 5, 10, 30, 60, 120, 240, 480, 720, then 1440 minutes (11 attempts, then `dead`). Each attempt has a new timestamp and signature. A `410 Gone` stops retries for that delivery.

Stored bodies are removed after 30 days. The delivery row stays 90 days, and a wiped body cannot be sent again. Owners and admins are emailed if a delivery dies, at most once a day per endpoint. You can retry or cancel a delivery from Developers.

The endpoint must be public HTTPS. Private addresses, link-local hosts, and credentials in the URL are rejected.
