---
id: api-webhooks
title: Webhooks
summary: Register an endpoint, list what was delivered, and replay a delivery.
faces: ["public", "agent"]
section: back-end
group: API reference
slug: webhooks
order: 40
next: ["api-stripe-compatibility"]
---
# Webhooks

Webhooks tell your server what happened without it polling. Register an endpoint, and read the deliveries
when you need to see what was sent and what your endpoint answered.

An endpoint names the events it wants. The vocabulary below is closed: a filter naming anything else is
refused when you register it, rather than accepted and never delivered to. Matching is exact, so a
capitalised spelling is refused too.

<!-- generated from docs/catalogue/webhook-events.json — edit the catalogue, not this -->

## The events we send

Every delivery carries an event id beginning `evt_` and declares API version `2026-09-01`.

| event | when it is sent | what it means |
|---|---|---|
| `payment.captured` | Every capture, whether it was an immediate sale or the later capture of a payment you had held. | The money has been taken. |
| `payment.held` | A payment is authorised without being captured. | The card was approved and the amount is reserved. Nothing is taken until the hold is decided — by you, or by ripper on your rules. |
| `payment.voided` | A held payment's authorisation is released instead of captured. | A hold was released, and the shopper's reserved amount goes back. |
| `payment.declined` | An attempt ends in a decline. | The card was refused. Nothing was taken. |
| `payment.failed` | An attempt ends in an error rather than a decline. | Something went wrong and the payment did not complete. Nothing was taken. |

**`payment.captured`.** One word covers both, so you do not have to reconcile two events that mean the same thing.

**`payment.declined`.** A decline is never folded into a failure. Filtering on declines asks a commercial question; filtering on failures asks an operational one.

### Retired event types

`payment.succeeded` is retired. A capture became one word whether it was immediate or followed a hold. Registering a filter for it now fails, and the refusal names `payment.captured` as the word that replaced it. Historical delivery rows keep the word they were sent with, so a delivery log may show `payment.succeeded` on older rows.

<!-- end generated -->

<!-- generated from the webhooks object in docs/api/openapi.yaml — edit the spec, not this -->

## What your endpoint receives

Every delivery is a `POST` with a JSON body in this envelope. Answer `2xx` once you have stored it;
anything else is retried. Treat a delivery you have already seen as a duplicate rather than an error —
the same event may arrive more than once.

| field | type | always sent | what it is |
|---|---|---|---|
| `id` | string | yes | The event's id. The same event to the same endpoint always has the same id. |
| `type` | string | yes | Which event this is. The same five names your endpoint filter uses. |
| `created` | string | yes | When ripper made this event, not when it was delivered. A retry of the same event keeps the original time. |
| `api_version` | string | yes | The shape of this body. It changes only when the shape does, so you can branch on it if you keep old handlers running. |
| `merchant_id` | string | yes | Your merchant account id (`m_` followed by 24 hexadecimal characters). |
| `sequence` | integer | yes | 1 for a payment's outcome; 2 for the later decision on a held payment. |
| `data` | object | yes | The payment this event is about. |

### The `data` block

What the event is about.

| field | type | always sent | what it is |
|---|---|---|---|
| `payment_id` | string | yes | The payment. Use it to read the payment back, and as the key to make your handler idempotent. |
| `checkout_session_id` | string | yes | The session this payment came from. |
| `amount` | integer | yes | What was charged, in the currency’s minor unit. |
| `currency` | string | yes | A three-letter ISO 4217 code, for example `GBP`. |
| `status` | string | yes | The payment’s status at the moment this event was made. |
| `mode` | string | yes | `test` or `live`. A test payment moved no money. |
| `decline` | object or null | yes | Why the payment was declined, or `null` when it was not. |
| `metadata` | object | yes | The metadata you sent when you created the session, keys exactly as you sent them. |
| `amount_verified` | boolean | yes | Whether ripper could check the amount against your price list before charging it. When it could not, an approved payment is held for you to review rather than captured. |
| `capture_state` | string or null | yes | Where the money is: captured, authorised and waiting, or voided. |
| `items` | array or null | yes | The basket, or `null` when the session carried no lines. |
| `basket_total` | integer or null | yes | What the lines add up to, in minor units, tax inclusive. `null` when there are no lines. |
| `tax_total` | integer or null | yes | The tax across the lines, in minor units. `null` when no line carries a `tax_amount`. |
| `customer` | object | yes | The shopper, as you or the checkout supplied them. |
| `hold` | object or null | yes | Why this payment is on hold and what was decided, or `null` when it was never held. |
| `order_reference` | string or null | yes | Your own reference for this order, or `null` when none was sent. |

### Checking a delivery really came from ripper

| parameter | in | required | what it is |
|---|---|---|---|
| `Rip-Signature` | header | yes | `t=<unix seconds>,v1=<hex HMAC-SHA256>`. To verify: take the raw request body exactly as received, compute HMAC-SHA256 over `<t>.<raw body>` with the endpoint's signing secret (the whole string, prefix included, as UTF-8), hex-encode it in lower case, and compare it with `v1` in constant time. Reject a `t` that is too far from your clock (five minutes is a sensible tolerance) so a captured request cannot be replayed later. |
| `Rip-Idempotency-Key` | header | yes | The same value for every attempt and replay of one event to one endpoint, and the row's `event_id` in the delivery list. Use it to ignore an event you have already handled. |

### An example `payment.captured` delivery

```json
{
  "id": "evt_fedcba9876543210fedcba98",
  "type": "payment.captured",
  "created": "2026-09-16T10:17:42.0000000+00:00",
  "api_version": "2026-09-01",
  "merchant_id": "m_your_merchant_id",
  "sequence": 1,
  "data": {
    "payment_id": "pay_89abcdef0123456789abcdef",
    "checkout_session_id": "cs_0123456789abcdef01234567",
    "amount": 2500,
    "currency": "GBP",
    "status": "captured",
    "mode": "test",
    "decline": null,
    "metadata": {
      "order_id": "1042"
    },
    "amount_verified": true,
    "capture_state": "captured",
    "customer": {
      "email": "shopper@example.com",
      "name": "Sam Shopper",
      "account_created": null,
      "account_id": "your-customer-4821",
      "metadata": {
        "tier": "gold"
      }
    },
    "hold": null,
    "order_reference": "ORDER-1042",
    "items": [
      {
        "kind": "product",
        "sku": "SKU-1",
        "name": "Merino scarf",
        "quantity": 1,
        "unit_amount": 2000,
        "tax_amount": 333,
        "tax_rate": 2000,
        "type": "apparel"
      },
      {
        "kind": "shipping",
        "sku": null,
        "name": "Next-day delivery",
        "quantity": 1,
        "unit_amount": 500,
        "tax_amount": 83,
        "tax_rate": 2000,
        "type": null
      }
    ],
    "basket_total": 2500,
    "tax_total": 416
  }
}
```

### An example `payment.held` delivery

```json
{
  "id": "evt_fedcba9876543210fedcba98",
  "type": "payment.held",
  "created": "2026-09-16T10:17:42.0000000+00:00",
  "api_version": "2026-09-01",
  "merchant_id": "m_your_merchant_id",
  "sequence": 1,
  "data": {
    "payment_id": "pay_89abcdef0123456789abcdef",
    "checkout_session_id": "cs_0123456789abcdef01234567",
    "amount": 2500,
    "currency": "GBP",
    "status": "held",
    "mode": "test",
    "decline": null,
    "metadata": {},
    "amount_verified": false,
    "capture_state": "authorised",
    "customer": {
      "email": "shopper@example.com",
      "name": null,
      "account_created": null
    },
    "hold": {
      "status": "pending_review",
      "reason": "no_price_list",
      "detail": null,
      "deadline": "2026-09-21T10:17:42.0000000+00:00",
      "decided_by": null,
      "decided_at": null,
      "operation_refusal": null
    },
    "order_reference": null,
    "items": null,
    "basket_total": null,
    "tax_total": null
  }
}
```

### An example `payment.voided` delivery

```json
{
  "id": "evt_ab1234567890abcdef123456",
  "type": "payment.voided",
  "created": "2026-09-16T10:19:03.0000000+00:00",
  "api_version": "2026-09-01",
  "merchant_id": "m_your_merchant_id",
  "sequence": 2,
  "data": {
    "payment_id": "pay_89abcdef0123456789abcdef",
    "checkout_session_id": "cs_0123456789abcdef01234567",
    "amount": 2500,
    "currency": "GBP",
    "status": "voided",
    "mode": "test",
    "decline": null,
    "metadata": {
      "order_id": "1042"
    },
    "amount_verified": true,
    "capture_state": "released",
    "customer": {
      "email": "shopper@example.com",
      "name": "Sam Shopper",
      "account_created": null,
      "account_id": "your-customer-4821",
      "metadata": {
        "tier": "gold"
      }
    },
    "hold": {
      "status": "released",
      "reason": "no_price_list",
      "detail": null,
      "deadline": "2026-09-21T10:17:42.0000000+00:00",
      "decided_by": "merchant",
      "decided_at": "2026-09-16T10:19:03.0000000+00:00",
      "operation_refusal": null
    },
    "order_reference": "ORDER-1042",
    "items": null,
    "basket_total": null,
    "tax_total": null
  }
}
```

### An example `payment.declined` delivery

```json
{
  "id": "evt_cd1234567890abcdef123456",
  "type": "payment.declined",
  "created": "2026-09-16T10:19:03.0000000+00:00",
  "api_version": "2026-09-01",
  "merchant_id": "m_your_merchant_id",
  "sequence": 1,
  "data": {
    "payment_id": "pay_89abcdef0123456789abcdef",
    "checkout_session_id": "cs_0123456789abcdef01234567",
    "amount": 2500,
    "currency": "GBP",
    "status": "declined",
    "mode": "test",
    "decline": {
      "code": "card_declined",
      "category": "hard",
      "reason": "Do not honour"
    },
    "metadata": {
      "order_id": "1042"
    },
    "amount_verified": true,
    "capture_state": null,
    "customer": {
      "email": "shopper@example.com",
      "name": "Sam Shopper",
      "account_created": null,
      "account_id": "your-customer-4821",
      "metadata": {
        "tier": "gold"
      }
    },
    "hold": null,
    "order_reference": "ORDER-1042",
    "items": null,
    "basket_total": null,
    "tax_total": null
  }
}
```

### An example `payment.failed` delivery

```json
{
  "id": "evt_ef1234567890abcdef123456",
  "type": "payment.failed",
  "created": "2026-09-16T10:19:03.0000000+00:00",
  "api_version": "2026-09-01",
  "merchant_id": "m_your_merchant_id",
  "sequence": 1,
  "data": {
    "payment_id": "pay_89abcdef0123456789abcdef",
    "checkout_session_id": "cs_0123456789abcdef01234567",
    "amount": 2500,
    "currency": "GBP",
    "status": "failed",
    "mode": "test",
    "decline": {
      "code": "provider_error",
      "category": "provider_error",
      "reason": "The acquirer did not answer in time"
    },
    "metadata": {
      "order_id": "1042"
    },
    "amount_verified": true,
    "capture_state": null,
    "customer": {
      "email": "shopper@example.com",
      "name": "Sam Shopper",
      "account_created": null,
      "account_id": "your-customer-4821",
      "metadata": {
        "tier": "gold"
      }
    },
    "hold": null,
    "order_reference": "ORDER-1042",
    "items": null,
    "basket_total": null,
    "tax_total": null
  }
}
```

<!-- end generated -->

<!-- generated from docs/api/openapi.yaml — edit the spec, not this -->

## Webhook deliveries

### GET /v1/webhooks/deliveries

List webhook deliveries

**Authentication.** Your secret key, in `X-Ripper-Api-Key`. Server only.

Returns delivery attempts to your endpoints, newest first, a page at a time. Pass `next_cursor` back
as `after` for the next page, with the same filters. Each row is one attempt; a delivery that was
retried has one row per attempt. Rows are kept for 30 days.

Requires a secret key with the `manage_webhooks` permission.

| parameter | in | required | what it is |
|---|---|---|---|
| `limit` | query | no | Rows per page, 1 to 100. Defaults to 20. |
| `after` | query | no | The `next_cursor` from the previous page. |
| `status` | query | no | Only rows with this delivery status. |
| `event_type` | query | no | Only rows for this delivery type, spelled as the row's `event_type` (for example `webhook.payment.captured`). |

**200 —** One page of delivery rows.

```json
{
  "data": [
    {
      "delivery_id": "0123456789abcdef0123456789abcdef",
      "replay_of": null,
      "merchant_id": "m_your_merchant_id",
      "event_id": "evt:pay_89abcdef0123456789abcdef#payment.captured#whep_0123456789abcdef01234567",
      "event_type": "webhook.payment.captured",
      "attempt_number": 1,
      "status": "succeeded",
      "occurred_at": "2026-09-16T10:17:43.0000000+00:00",
      "endpoint_url": "https://shop.example.com/webhooks/ripper",
      "response_status_code": 200,
      "response_body": "ok",
      "next_retry_at": null,
      "replayable": false,
      "replay_expires_at": "2026-10-16T10:17:42.0000000+00:00"
    }
  ],
  "has_more": false,
  "next_cursor": null
}
```

**Fields in the response**

| field | type | always sent | what it is |
|---|---|---|---|
| `data` | array | yes | This page of deliveries, newest first. |
| `has_more` | boolean | yes | Whether there are older deliveries beyond this page. |
| `next_cursor` | string or null | yes | Pass this as `cursor` to get the next page, or `null` when this is the last. |

**`data` fields**

| field | type | always sent | what it is |
|---|---|---|---|
| `delivery_id` | string | yes | The delivery this attempt belongs to. Replay by this id. |
| `replay_of` | string or null | yes | The delivery this one replays. |
| `merchant_id` | string | yes | Your merchant account. |
| `event_id` | string | yes | The value sent in the delivery's `Rip-Idempotency-Key` header. |
| `event_type` | string | yes | `webhook.` followed by the event type, for example `webhook.payment.captured`. |
| `attempt_number` | integer | yes | 1 for the first attempt. 0 marks a replay that was accepted and not yet attempted. |
| `status` | string | yes | How this attempt ended. |
| `occurred_at` | string | yes | When this attempt was made. |
| `endpoint_url` | string or null | yes | Where it was sent. |
| `response_status_code` | integer or null | yes | What your endpoint answered, or `null` if it never answered. |
| `response_body` | string or null | yes | The first 1,024 characters of your endpoint's answer. |
| `next_retry_at` | string or null | yes | When ripper will try again, or `null` when it will not. |
| `replayable` | boolean | yes | Whether you can ask for this delivery to be sent again. |
| `replay_expires_at` | string or null | yes | After this, the delivery can no longer be replayed. |

**When it refuses**

- `400` — `invalid_pagination`: `limit` is outside 1 to 100, `offset` was sent, or `after` is not a cursor for these filters. `invalid_status`: `status` is not one of the delivery statuses.
- `401` — `unknown_principal`: no key, or a key that is not recognised. The body is `{"code": …}`.
- `403` — The key was recognised but refused: `key_permission_denied` (no `manage_webhooks`), `key_revoked`, `key_expired`, `key_ip_blocked` or `key_origin_blocked`. The body is `{"code": …}`.
- `429` — `rate_limit_exceeded`: too many requests of this kind for your account this minute. By default reads allow 500 a minute, creates and deletes 100, and replays 10.
- `500` — `internal_error`: something failed on ripper's side.

Operation id `listWebhookDeliveries`, for code generators.

### POST /v1/webhooks/deliveries/{delivery_id}/replay

Replay a webhook delivery

**Authentication.** Your secret key, in `X-Ripper-Api-Key`. Server only.

Queues a failed delivery to be sent again. Only a delivery whose status is `dead_lettered` can be
replayed (its row shows `replayable: true`), within 30 days of the original delivery. The replay is
a new delivery with its own `delivery_id`. Send no body.

Replays are limited to 10 a minute per account, whatever your plan.

Requires a secret key with the `manage_webhooks` permission.

| parameter | in | required | what it is |
|---|---|---|---|
| `delivery_id` | path | yes | The `delivery_id` from a delivery row. |
| `Rip-Idempotency-Key` | header | no | Optional but recommended. A retry with the same key returns the first response. Without a key the response carries `Rip-Idempotency-Warning`. `Idempotency-Key` is accepted as well; sending two different keys is refused. |

**202 —** The replay was queued.

```json
{
  "delivery_id": "89abcdef0123456789abcdef01234567",
  "replay_of": "0123456789abcdef0123456789abcdef"
}
```

**Fields in the response**

| field | type | always sent | what it is |
|---|---|---|---|
| `delivery_id` | string | yes | The new delivery. |
| `replay_of` | string | yes | The delivery you replayed. |

**When it refuses**

- `400` — `invalid_idempotency_key`: the idempotency key is unusable, for example two different keys on one request.
- `401` — `unknown_principal`: no key, or a key that is not recognised. The body is `{"code": …}`.
- `403` — The key was recognised but refused: `key_permission_denied` (no `manage_webhooks`), `key_revoked`, `key_expired`, `key_ip_blocked` or `key_origin_blocked`. The body is `{"code": …}`.
- `404` — `delivery_not_found`: no webhook delivery with this id on your account.
- `409` — `delivery_not_dead_lettered`: the delivery has not been given up on yet, so it cannot be replayed. `replay_in_progress`: another replay of this delivery was accepted at the same moment. `idempotency_key_reused` or `idempotency_key_in_use`: see the idempotency key.
- `422` — `replay_window_expired`: the original delivery is more than 30 days old. `replay_chain_broken`: an earlier delivery in this replay chain is no longer stored.
- `429` — `rate_limit_exceeded`: too many requests of this kind for your account this minute. By default reads allow 500 a minute, creates and deletes 100, and replays 10.
- `500` — `internal_error`: something failed on ripper's side.
- `503` — `idempotency_store_unavailable`: the key could not be checked. Retry with the same key after `Retry-After` seconds.

Operation id `replayWebhookDelivery`, for code generators.

## Webhook endpoints

### POST /v1/webhooks/endpoints

Create a webhook endpoint

**Authentication.** Your secret key, in `X-Ripper-Api-Key`. Server only.

Registers an https URL that receives payment webhooks. The response is the only time the
`signing_secret` is shown: store it where your webhook handler can read it.

Leave `event_filter` out to receive every payment event. A filter names the event types to receive,
from the list this route accepts.

Requires a secret key with the `manage_webhooks` permission.

| parameter | in | required | what it is |
|---|---|---|---|
| `Rip-Idempotency-Key` | header | no | Optional but recommended. A retry with the same key returns the first response. Without a key the response carries `Rip-Idempotency-Warning`. `Idempotency-Key` is accepted as well; sending two different keys is refused. |

**The body you send**

```json
{
  "url": "https://shop.example.com/webhooks/ripper"
}
```

**Fields in the body you send**

| field | type | always sent | what it is |
|---|---|---|---|
| `url` | string | yes | An absolute https URL that is publicly reachable. |
| `event_filter` | array | no | The event types to receive, from the five under `webhooks`. Leave it out, or send an empty list, to receive every payment event. |

**201 —** The endpoint was created, or a retry with the same idempotency key replayed it.

```json
{
  "id": "whep_0123456789abcdef01234567",
  "url": "https://shop.example.com/webhooks/ripper",
  "event_filter": [],
  "status": "active",
  "signing_secret": "rip_whsec_EXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLEEXA",
  "created_at": "2026-09-16T10:00:00.0000000+00:00"
}
```

**Fields in the response**

| field | type | always sent | what it is |
|---|---|---|---|
| `id` | string | yes | The endpoint. Use it to remove the endpoint or to list its deliveries. |
| `url` | string | yes | Where deliveries are sent. |
| `event_filter` | array | yes | The event types this endpoint receives. Empty means every payment event. |
| `status` | string | yes | Whether this endpoint is still receiving deliveries. Removing an endpoint disables it rather than deleting it. |
| `created_at` | string | yes | When the endpoint was registered. |
| `signing_secret` | string | yes | The secret that signs this endpoint's webhooks. Shown only in this response. |

**When it refuses**

- `400` — `invalid_url`: the URL is missing, not absolute, not https, or points at a private or local address. `unknown_event_type`: a filter entry is not an accepted event type; `allowed` lists the accepted ones. `invalid_idempotency_key`: the idempotency key is unusable (for example, two different keys on one request).
- `401` — `unknown_principal`: no key, or a key that is not recognised. The body is `{"code": …}`.
- `403` — The key was recognised but refused: `key_permission_denied` (no `manage_webhooks`), `key_revoked`, `key_expired`, `key_ip_blocked` or `key_origin_blocked`. The body is `{"code": …}`.
- `409` — `idempotency_key_reused`: the key was used with a different request. `idempotency_key_in_use`: a request with this key is still running.
- `429` — `rate_limit_exceeded`: too many requests of this kind for your account this minute. By default reads allow 500 a minute, creates and deletes 100, and replays 10.
- `500` — `internal_error`: something failed on ripper's side.
- `503` — `idempotency_store_unavailable`: the key could not be checked. Retry with the same key after `Retry-After` seconds.

Operation id `createWebhookEndpoint`, for code generators.

### DELETE /v1/webhooks/endpoints/{endpoint_id}

Delete a webhook endpoint

**Authentication.** Your secret key, in `X-Ripper-Api-Key`. Server only.

Stops deliveries to an endpoint. An unknown id, another account's id and an endpoint already deleted
all answer the same 404.

Requires a secret key with the `manage_webhooks` permission.

| parameter | in | required | what it is |
|---|---|---|---|
| `endpoint_id` | path | yes | The endpoint's id. |
| `Rip-Idempotency-Key` | header | no | Optional but recommended. A retry with the same key returns the first response. Without a key the response carries `Rip-Idempotency-Warning`. `Idempotency-Key` is accepted as well; sending two different keys is refused. |

**204 —** The endpoint was deleted. There is no body.

**When it refuses**

- `400` — `invalid_idempotency_key`: the idempotency key is unusable, for example two different keys on one request.
- `401` — `unknown_principal`: no key, or a key that is not recognised. The body is `{"code": …}`.
- `403` — The key was recognised but refused: `key_permission_denied` (no `manage_webhooks`), `key_revoked`, `key_expired`, `key_ip_blocked` or `key_origin_blocked`. The body is `{"code": …}`.
- `404` — `endpoint_not_found`: no active endpoint with this id on your account.
- `409` — `idempotency_key_reused`: the key was used with a different request. `idempotency_key_in_use`: a request with this key is still running.
- `429` — `rate_limit_exceeded`: too many requests of this kind for your account this minute. By default reads allow 500 a minute, creates and deletes 100, and replays 10.
- `500` — `internal_error`: something failed on ripper's side.
- `503` — `idempotency_store_unavailable`: the key could not be checked. Retry with the same key after `Retry-After` seconds.

Operation id `deleteWebhookEndpoint`, for code generators.

### GET /v1/webhooks/endpoints

List webhook endpoints

**Authentication.** Your secret key, in `X-Ripper-Api-Key`. Server only.

Returns your active endpoints. With none registered the list is empty. Signing secrets are never
returned here.

Requires a secret key with the `manage_webhooks` permission.

**200 —** Your active endpoints.

```json
{
  "endpoints": [
    {
      "id": "whep_0123456789abcdef01234567",
      "url": "https://shop.example.com/webhooks/ripper",
      "event_filter": [],
      "status": "active",
      "created_at": "2026-09-16T10:00:00.0000000+00:00"
    }
  ]
}
```

**Fields in the response**

| field | type | always sent | what it is |
|---|---|---|---|
| `endpoints` | array | yes | Your endpoints. An empty array when you have none — never a 404. |

**`endpoints` fields**

| field | type | always sent | what it is |
|---|---|---|---|
| `id` | string | yes | The endpoint. Use it to remove the endpoint or to list its deliveries. |
| `url` | string | yes | Where deliveries are sent. |
| `event_filter` | array | yes | The event types this endpoint receives. Empty means every payment event. |
| `status` | string | yes | Whether this endpoint is still receiving deliveries. Removing an endpoint disables it rather than deleting it. |
| `created_at` | string | yes | When the endpoint was registered. |

**When it refuses**

- `401` — `unknown_principal`: no key, or a key that is not recognised. The body is `{"code": …}`.
- `403` — The key was recognised but refused: `key_permission_denied` (no `manage_webhooks`), `key_revoked`, `key_expired`, `key_ip_blocked` or `key_origin_blocked`. The body is `{"code": …}`.
- `429` — `rate_limit_exceeded`: too many requests of this kind for your account this minute. By default reads allow 500 a minute, creates and deletes 100, and replays 10.
- `500` — `internal_error`: something failed on ripper's side.

Operation id `listWebhookEndpoints`, for code generators.

<!-- end generated -->
