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.
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.
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 #
{
"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 #
{
"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 #
{
"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 #
{
"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 #
{
"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
}
}
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.
{
"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:limitis outside 1 to 100,offsetwas sent, orafteris not a cursor for these filters.invalid_status:statusis 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(nomanage_webhooks),key_revoked,key_expired,key_ip_blockedorkey_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.
{
"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(nomanage_webhooks),key_revoked,key_expired,key_ip_blockedorkey_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_reusedoridempotency_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 afterRetry-Afterseconds.
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
{
"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.
{
"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;allowedlists 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(nomanage_webhooks),key_revoked,key_expired,key_ip_blockedorkey_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 afterRetry-Afterseconds.
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(nomanage_webhooks),key_revoked,key_expired,key_ip_blockedorkey_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 afterRetry-Afterseconds.
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.
{
"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(nomanage_webhooks),key_revoked,key_expired,key_ip_blockedorkey_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.