# Webhooks

Let Payer come to you. Register a signed HTTPS endpoint and get a POST the moment a checkout session changes state.

Rather than poll [Retrieve a session](/developers/docs/checkout#retrieve-a-session) for a status change, register an endpoint and let Payer come to you. When a checkout session reaches a lifecycle event, Payer sends a signed `POST` to your HTTPS URL. Each delivery carries a `Payer-Signature` header you verify with the endpoint's signing secret.

Endpoints are managed from the **Developers page in your Payer dashboard** (admin role) — one set per environment, not with the checkout API keys. Test endpoints receive events from your `_test_` sessions, live endpoints from `_live_` sessions.

## Events

Subscribe each endpoint to one or more of these events:

| Event | Sent when |
| --- | --- |
| `checkout.session.created` | A checkout session was created. |
| `checkout.session.completed` | Payment settled. |
| `checkout.session.failed` | A confirmation attempt failed terminally. |
| `checkout.session.cancelled` | The session was cancelled. |
| `checkout.session.expired` | The session's 24-hour window elapsed unpaid. |

## The delivery payload

Every delivery is a JSON envelope. The `data.object` is a snapshot of the checkout session at the moment the event fired:

```json title="checkout.session.completed"
{
  "id": "evt_2f8c1a9b-7d6e-4f0a-b1c2-3d4e5f6a7b8c",
  "type": "checkout.session.completed",
  "api_version": "2026-07-01",
  "created": 1753600000,
  "data": {
    "object": {
      "id": "9b1c8f2e-3d4a-4f11-a0c2-1e2d3c4b5a69",
      "reference": "order_10482",
      "amount": 2490,
      "currency": "MVR",
      "status": "COMPLETED",
      "title": "Order #10482",
      "description": null,
      "payment": {
        "id": "6a5b4c3d-2e1f-4a0b-9c8d-7e6f5a4b3c2d",
        "completed_at": 1753600000
      },
      "merchant": {
        "id": "42",
        "name": "Acme Store"
      }
    }
  }
}
```

### Envelope fields

| Field | Type | Description |
| --- | --- | --- |
| `id` | string | Event id, prefixed `evt_`. Stable across every endpoint that receives it — use it to de-duplicate. |
| `type` | string | The event name from the table above. |
| `api_version` | string | The payload schema version (`2026-07-01`). |
| `created` | integer | Unix timestamp of the event. |
| `data.object` | object | The checkout session snapshot. |

### The session object

| Field | Type | Notes |
| --- | --- | --- |
| `amount` | integer | In **laari** (minor units) here — unlike the GraphQL query. |
| `currency` | string | Always `MVR`. |
| `status` | string | `INITIATED` · `COMPLETED` · `CANCELLED` · `EXPIRED`. |
| `payment` | object | Present only once the session is `COMPLETED`. Carries the payment id and `completed_at`. |
| `merchant` | object | The receiving merchant's id (stringified) and name. |

> [!NOTE]
> A payload never contains tokens, secrets, wallet ids or card data. Treat it as a *notification*: fetch anything sensitive yourself, authenticated, using the ids it carries.

## Delivery headers

| Header | Example | Description |
| --- | --- | --- |
| `Payer-Signature` | `t=1753600000, v1=8f4e2c1a…` | Timestamped HMAC. See below. |
| `Payer-Event-Id` | `evt_2f8c1a9b-…` | Same id as `id` in the body. |
| `Payer-Event-Type` | `checkout.session.completed` | Same as `type` in the body. |
| `User-Agent` | `Payer-Webhooks/1.0` | Fixed. |

## Verifying signatures

Each endpoint has its own signing secret — a `whsec_` value shown **once** when you register the endpoint or rotate the secret, and never retrievable again. Payer signs every delivery with it and sets the `Payer-Signature` header:

```http title="Payer-Signature header"
Payer-Signature: t=1753600000, v1=8f4e2c1a9b7d6e5f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f
```

The header carries a Unix timestamp `t` and a signature `v1`, the hex `HMAC-SHA256` of `${t}.${raw_body}` keyed with your signing secret. To verify:

1. Recompute the HMAC over the **raw request body** — before any JSON parsing, over the exact bytes Payer sent.
2. Compare it to `v1` in constant time.
3. Reject deliveries whose `t` is more than five minutes from now, to blunt replays.

During a secret rotation the header may carry more than one `v1` value — a match against any one is valid.

```js title="Verify a delivery (Node.js)"
import { createHmac, timingSafeEqual } from "node:crypto";

const TOLERANCE_SECONDS = 300; // reject deliveries older than 5 minutes

/**
 * Verify a Payer-Signature header against the raw request body.
 * Header shape: t=<unix>, v1=<hex>[, v1=<hex>...].
 * Verify before JSON.parse — sign over the exact bytes Payer sent.
 */
export function verifyPayerSignature(rawBody, header, secret) {
  let timestamp = null;
  const signatures = [];
  for (const part of header.split(",")) {
    const [key, value] = part.trim().split("=");
    if (key === "t") timestamp = Number(value);
    else if (key === "v1" && value) signatures.push(value);
  }
  if (!Number.isInteger(timestamp) || signatures.length === 0) return false;
  if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;

  const expected = createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");
  const a = Buffer.from(expected);

  // Accept a match against any v1 entry (constant-time comparison).
  return signatures.some((sig) => {
    const b = Buffer.from(sig);
    return a.length === b.length && timingSafeEqual(a, b);
  });
}
```

## Responding, retries and replay

Return any `2xx` status to acknowledge a delivery. Anything else — or a timeout past **10 seconds** — is treated as a failure and retried.

- Payer retries with a growing backoff: after **1, 5 and 15 minutes**, then **1, 3, 6 and 12 hours** — up to **8 attempts** spanning roughly a day.
- Because retries and de-duplication are real, your handler must be **idempotent**: key on the `evt_` id and ignore one you've already processed.
- Every attempt is recorded in the **delivery log** on the Developers page, with the response status and body snippet. You can **resend** any delivery from there; it's re-signed and re-sent verbatim.

> [!TIP]
> Acknowledge fast, work later. Verify the signature, enqueue the event id for background processing, and return `200` immediately — don't do slow work inside the request Payer is timing.

## Managing endpoints

From the dashboard you can register an endpoint, choose its events, enable or disable it without losing its configuration, rotate its signing secret, and delete it.

| Setting | Type | Notes |
| --- | --- | --- |
| `url` | string (https) | Must start with `https://`. Max 2048 characters. |
| `events` | array | One or more event types from the catalogue. |
| `description` | string | A label to recognise the endpoint. |
| `environment` | `live` · `test` | Which environment's events it receives. Set once. |

Rotating a secret issues a new `whsec_` immediately; update your endpoint before the old one stops verifying. Deleting an endpoint revokes its secret at once.
