# Webhooks

> Signed, retried event deliveries — the authoritative way to learn a session's decision.

Source: https://docs.okyc.stream/backend/webhooks

## Add an endpoint

In the dashboard open **Developers → Webhooks**, add your HTTPS URL and choose events (`*`, `session.*` or
single types). The signing secret (`whsec_…`) is shown when you create the endpoint and can be revealed later.
Or use the API: `POST /v1/webhook_endpoints` with `{ "url", "events" }` — the secret is returned once.

Endpoints belong to a mode: test sessions deliver only to test-mode endpoints. In production, URLs must be
`https` and publicly reachable (no private or loopback addresses).

## Events

| Type                             | When                                                                              |
| -------------------------------- | --------------------------------------------------------------------------------- |
| `session.created`                | A session was created.                                                            |
| `session.started`                | The applicant opened the flow.                                                    |
| `session.processing`             | All steps were submitted; checks and rules are running.                           |
| `session.approved`               | Approved, by the rules or by a reviewer.                                          |
| `session.declined`               | Declined, by the rules or by a reviewer.                                          |
| `session.review`                 | Waiting for a person in your team to decide.                                      |
| `session.resubmission_requested` | A reviewer asked the applicant to redo some steps.                                |
| `session.expired`                | The session expired before it was finished.                                       |
| `session.cancelled`              | The session was cancelled.                                                        |
| `check.completed`                | An async [direct check](https://docs.okyc.stream/backend/sessions.md#direct-checks-no-hosted-flow) finished. |
| `screening.hit`                  | Sanctions or PEP screening found a potential match.                               |

## Payload

```json
{
  "id": "evt_…",
  "type": "session.approved",
  "created": 1790000000,
  "mode": "test",
  "data": {
    "session": {
      "id": "ses_…",
      "reference_id": "user_123",
      "status": "approved",
      "type": "kyc",
      "decision": "approved",
      "decision_source": "auto",
      "parent_session_id": null
    }
  }
}
```

Payloads carry ids and statuses only, never personal data. Fetch the session for details.

## Verify the signature

Every delivery has an `OKYC-Signature` header:

```text
OKYC-Signature: t=1790000000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
```

`v1` is `hex(HMAC_SHA256(secret, t + "." + raw_body))`. With `@okyc/node`, pass the **raw** body (not parsed
JSON) to `webhooks.constructEvent`; it checks the signature in constant time and rejects timestamps older than
5 minutes:

```js title="server.mjs"
// Webhooks: verify against the RAW body, dedupe on event.id, then fetch details from the API.
const seen = new Set();
app.post('/okyc/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
  let event;
  try {
    event = await okyc.webhooks.constructEvent(
      req.body,
      req.header('okyc-signature'),
      process.env.OKYC_WEBHOOK_SECRET,
    );
  } catch {
    return res.sendStatus(400);
  }
  if (seen.has(event.id)) return res.sendStatus(200);
  seen.add(event.id);
  if (event.type === 'session.approved' || event.type === 'session.declined') {
    const session = await okyc.sessions.retrieve(event.data.session.id);
    console.log(`user ${session.reference_id} → ${session.status}`);
    // mark the user verified / rejected in your database
  }
  res.sendStatus(200);
});
```

In other languages:

1. Split the header on `,`; read `t` and every `v1`.
2. Reject if `|now − t| > 300` seconds.
3. Compute `HMAC_SHA256(secret, "<t>.<raw body>")` as lowercase hex.
4. Accept if it equals any `v1`, compared in constant time.

To test your handler, `generateTestHeader(secret, body)` from `@okyc/node` builds a valid header, and the
dashboard can send a test event to an endpoint.

## Delivery

* Respond with any `2xx` within 10 seconds. Anything else is retried with exponential backoff for up to 24 hours.
* Delivery is **at least once** and not ordered: dedupe on the event `id`, and treat the session you fetch as the
  truth.
* Every attempt is in the dashboard's delivery log, and you can replay events from there. `GET /v1/events`
  lists recent events for reconciliation.
