## Overview

Webhooks allow you to receive real-time notifications about events that occur in your Reservepay account. Instead of polling our API to check for updates, your server can listen for incoming requests from Reservepay.

## Setting up webhooks

To start receiving webhook notifications, connect a webhook endpoint to your account. You can manage webhooks through the API or from your merchant dashboard under **Developer Settings**.

You can connect several endpoints to one account. Each endpoint has its own `destination_url`, `events` to subscribe to, `signature_type`, and `api_version`. When an event happens, every active endpoint subscribed to it gets its own copy.

### Currently supported events

| Event | Description |
| :--- | :--- |
| `payment_authorized` | Triggered when a payment status transitions to `AUTHORIZED`. |
| `payment_completed` | Triggered when a payment status transitions to `SUCCESSFUL` or `FAILED`. |
| `payment_expired` | Triggered when a payment status transitions to `EXPIRED`. |
| `payment_voided` | Triggered when a payment status transitions to `VOIDED`. |
| `payment_reversed` | Triggered when a payment status transitions to `REVERSED`. |
| `payout_completed` | Triggered when a payout status transitions to `SUCCESSFUL` or `FAILED`. |
| `topup_settled` | Triggered when a topup status transitions to `SUCCESSFUL`. |

`payment_completed` and `payout_completed` fire for both success and failure. Always read the `status` in the payload before you fulfill an order or mark a payout as paid. For what each status means, see [Payment lifecycle](/guides/payment-lifecycle#statuses).

Track refunds and disputes by polling, since they don't have events. Poll refunds with `merchants/find-refund` (see [Refunds, voids, and reversals](/guides/refunds-voids-and-reversals)) and disputes with `merchants/list-disputes` (see [Disputes](/guides/disputes)).

## Handling webhook requests

When an event occurs, Reservepay sends a `POST` request to your destination URL with a JSON payload.

### Payload

Payment and payout deliveries include the following fields. See the [Webhook reference](/webhooks/merchants) for each event's complete payload:

* `event_id`: A unique ID for the event, starting with `evt_`. It stays the same across retries and across endpoints, so use it to spot duplicates.
* `status`: The payment or payout status when the event happened.
* `delivered_at`: When this delivery attempt was sent, as a Unix timestamp. It changes on every retry, so two deliveries of the same event have different bodies and signatures. Spot duplicates with `event_id`, not with a hash of the body.
* `external_id`: Your own reference, if you set one.

### Verifying signatures

Every delivery is signed, so you can confirm it came from Reservepay. Check the signature before you trust the event. You choose the **signature type** when you connect the endpoint, and you can find the endpoint's `verification_key` in the dashboard.

Check the signature against the body exactly as you received it, before you parse it. Parsing and re-encoding the JSON changes the bytes and breaks the signature. Reject a request whose signature is missing or doesn't match.

| Signature type | Headers |
| :--- | :--- |
| `HMAC_SHA256` | `Reservepay-Signature` |
| `ED25519` | `Reservepay-Signature` |
| `SHA256` | `X-Webhook-Signature` |

The header value is prefixed with the scheme name, in the form `<scheme>=<hex digest>` (for example `hmac_sha256=<hex>`).

#### HMAC (SHA256)

Compute an HMAC-SHA256 digest of the raw request body using your verification key (Base64-decoded) and compare it to the hex value after `hmac_sha256=` in the `Reservepay-Signature` header.

This Node.js function does the check:

```ts
import { createHmac, timingSafeEqual } from "node:crypto"

function verifyReservepaySignature(rawBody, header, verificationKey) {
  const [scheme, signature] = (header ?? "").split("=")
  if (scheme !== "hmac_sha256" || !signature) return false

  const expected = createHmac("sha256", Buffer.from(verificationKey, "base64"))
    .update(rawBody)
    .digest("hex")

  const received = Buffer.from(signature)
  const computed = Buffer.from(expected)
  return received.length === computed.length && timingSafeEqual(received, computed)
}
```

Pass it the body exactly as it arrived. In Express, read the body with `express.raw()` rather than `express.json()`, then parse the JSON only after the signature checks out:

```ts
app.post("/webhooks/reservepay", express.raw({ type: "application/json" }), async (req, res) => {
  const valid = verifyReservepaySignature(
    req.body,
    req.get("Reservepay-Signature"),
    process.env.RESERVEPAY_WEBHOOK_KEY,
  )
  if (!valid) return res.sendStatus(401)

  const event = JSON.parse(req.body.toString("utf8"))
  await saveEventOnce(event) // Your code: store the event, skipping any event_id you already have.
  res.sendStatus(200)
})
```

#### Ed25519

Verify the hex-decoded signature after `ed25519=` in the `Reservepay-Signature` header against the raw request body using your verification key (Base64-decoded Ed25519 public key). Use a libsodium-compatible library for verification.

#### SHA256

For this legacy scheme, append the `verification_key` string exactly as returned by the API to the raw JSON body, with no separator. Compute the SHA256 hex digest of those bytes and compare it to the value after `sha256=` in the `X-Webhook-Signature` header. Do not Base64-decode the key for this scheme.

Choose `HMAC_SHA256` or `ED25519` for new endpoints.

### Responding to webhooks

Return any `2xx` status within 10 seconds. Reservepay ignores the response body. To stay within the time limit:

1. Check the signature.
2. Save the event, skipping it if you already have its `event_id`.
3. Return `200 OK`.
4. Do the actual work, such as fulfilling the order, in a background job.

If a duplicate arrives for an event you already saved, return `200 OK` again so Reservepay stops retrying.

### Retries and ordering

If your endpoint times out, can't be reached, or returns anything other than `2xx`, Reservepay retries with increasing delays, up to 20 attempts in total. Because of retries, the same event can arrive more than once, and events can arrive out of order.

Don't use arrival order or `delivered_at` to decide which status is newer. When you need the current status, call `merchants/find-payment` or `merchants/find-payout`.

### Checking delivery history

To see whether Reservepay reached your endpoint, call `merchants/list-payment-activities` with a `payment_id`, or `merchants/list-payout-activities` with a `payout_id`. Each event in the response has an `event_deliveries` array. Each entry covers one endpoint and one outcome:

* `url`: The endpoint Reservepay called.
* `status_code`: The HTTP status your endpoint returned, or `0` if it didn't respond.
* `failure_code`: Why the delivery failed, or `null` if it succeeded.
* `occurrences`: How many attempts ended with this outcome.
* `payload`: The body that was sent.

An event can have both failed and successful entries for the same endpoint when a retry succeeded. The event was delivered if any entry for that endpoint has `failure_code` set to `null`.

### Recovering missed events

After 20 failed attempts, Reservepay stops retrying the event, and deliveries can't be resent. If your endpoint was down for longer than the retry window, compare your records with the API instead, as described in [Recovering missed updates](/guides/reconciliation#recovering-missed-updates).

## Rotating the signing key

An endpoint keeps the same `verification_key` for as long as it exists. To move to a new `HMAC_SHA256` key:

1. Connect a second endpoint with `merchants/connect-webhook`, using the same destination URL, and store its `verification_key`.
2. While both endpoints are active, try each key and accept the request if either one matches. Every event is delivered to both endpoints with the same `event_id`, so your deduplication handles the overlap.
3. When deliveries to the new endpoint are succeeding, remove the old one through the dashboard.

`ED25519` endpoints all verify with Reservepay's public key, so connecting a new endpoint doesn't give you a new key.
