# CoverPay webhooks

Webhooks are the source of truth for whether an order is paid. Link's `onSuccess` and the public token exchange are fast paths; a webhook still arrives if the shopper closed their browser or your server missed the answer. Register endpoints in the dashboard (https://app.coverpayme.com, Webhooks).

## Events

| Event | When |
|---|---|
| `session.authorized` | The shopper approved a plan; funds are authorized (captured later). |
| `session.captured` | The order is paid. Mark it paid (once per `payment_id`). |
| `session.declined` | The provider declined. |
| `session.cancelled` | The payment was cancelled. |
| `session.expired` | The checkout expired unpaid. |
| `credit.installment_paid` | CoverPay plan: a later payment (2 to 4) was collected. |
| `credit.installment_failed` | CoverPay plan: a payment failed; it retries after 24 hours. |

## Payload

```json
{
  "id": "evt_3UO1…",
  "type": "session.captured",
  "created": "2026-10-07T21:05:00.000Z",
  "data": {
    "payment_id": "pi_3UO1…",
    "order_id": "order_123",
    "amount": 12000,
    "currency": "USD",
    "provider": "zip",
    "session_status": "captured"
  }
}
```

Treat the event as a hint. Before marking an order paid, read the payment back with your key: `GET /v1/bnpl/sessions/{payment_id}`. Check the order id and amount match your order.

## Verify the signature

Every delivery has `X-CoverPay-Signature-V2: t=<unix seconds>,v1=<hex>`, an HMAC-SHA256 of `"<t>.<raw body>"` with your endpoint's signing secret. Verify over the raw body before parsing, and reject timestamps more than 5 minutes old. The older `X-CoverPay-Signature` (HMAC of the body without a timestamp) is still sent; prefer V2.

```js
import crypto from 'crypto';

function verifyCoverPay(rawBody, header, secret) {
  if (!header) return false;
  const parts = header.split(',').map((p) => p.trim().split('='));
  const t = Number(parts.find(([k]) => k === 't')?.[1]);
  const v1s = parts.filter(([k]) => k === 'v1').map(([, v]) => v);
  if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > 300) return false;
  const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
  return v1s.some((v) => v.length === expected.length && crypto.timingSafeEqual(Buffer.from(v), Buffer.from(expected)));
}
```

## Delivery and retries

Answer 2xx once you have recorded the event. Any other answer, or no answer within the timeout, is retried after 1 minute, 5 minutes and 30 minutes. Events can arrive more than once and out of order: record each `payment_id` once.

## Checklist

- Verify `X-CoverPay-Signature-V2` before parsing; return 401 if it fails.
- Read the payment back with your key; check order id and amount.
- Record once per `payment_id` (the same order can also arrive through the public token exchange).
- Return 2xx quickly; do slow work afterwards.
