Ronda POSTs a signed JSON payload to your endpoint every time something happens. Deliveries are written to an outbox in the same database transaction as the change that caused them, so an event can never be lost because a request failed.
{
"id": "evt_9f3a2c…",
"object": "event",
"type": "payment.succeeded",
"mode": "live",
"created": 1786998900,
"data": {
"object": {
"id": "pay_a348cb…",
"object": "payment",
"amount": 12900,
"currency": "AMD",
"status": "succeeded",
"customer": "cus_1b4c…",
"subscription": "sub_5c1e…",
"attempt": 1,
"is_recovery": false
}
}
}| Header | Meaning |
|---|---|
ronda-signature | t=<unix>,v1=<hmac> — HMAC-SHA256 of `${t}.${body}` |
ronda-event-type | The event type, for routing without parsing |
ronda-delivery-id | The delivery (whd_…): one per event per endpoint, the same on every automatic retry. A replay from the dashboard is a new delivery with a new id. |
ronda-mode | test or live |
The timestamp is inside the signed material, which is what makes replay detection possible: a captured request cannot be re-sent later, because its t falls outside your tolerance and changing t invalidates the HMAC. Reject anything older than five minutes.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(",").map(kv => kv.split("=")));
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(parts.t));
if (!Number.isFinite(Number(parts.t)) || age > toleranceSeconds) return false;
const expected = createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1 ?? "");
return a.length === b.length && timingSafeEqual(a, b);
}Verify against the raw request body. Parsing and re-serialising the JSON changes the bytes and the signature will not match.
Any response outside 2xx is a failure. Ronda makes up to eight attempts in all — the first straight away, then retries after 10 seconds, 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 12 hours — then stops. An endpoint that fails twenty times in a row is disabled and shown as such in the dashboard, where you can also inspect and replay any delivery.
Reply quickly and do the work afterwards. If your handler takes longer than 8 seconds, Ronda treats the delivery as failed and will send it again.
A retry means your endpoint can see the same event twice. Deduplicate on the event id, which never changes — not on ronda-delivery-id, which stays the same across retries but is new when a delivery is replayed from the dashboard, and differs between two endpoints receiving the same event.
localhost on your machine; to receive test webhooks while developing, expose your local server through a tunnel (ngrok, Cloudflare Tunnel) and register its https URL. (A copy of Ronda running on your own machine does allow loopback, for its own test suite — ronda.sh never does.)| Event | When it fires |
|---|---|
payment.created | A payment intent was recorded, before the bank was called |
payment.succeeded | Money was taken. The one to act on for one-off payments. For a subscription started at checkout it fires before the subscription exists, so its subscription is null — act on subscription.activated or checkout.session.completed, which carry both. |
payment.failed | The bank declined. Check failure_code. |
payment.requires_action | The issuer forced a 3-D Secure step-up on a recurring charge |
payment.refunded | Fully refunded |
payment.partially_refunded | Partly refunded |
payment.settled | The bank settled the funds to the merchant; carries id, amount, currency and settled_at |
| Event | When it fires |
|---|---|
subscription.created | A subscription exists; it may be trialing |
subscription.activated | First charge succeeded — through the API or at checkout. A trial never sends it: a trial starts with subscription.created (status trialing) and turns paid with subscription.updated. So grant access from the subscription's status on any subscription.* event, not from this event alone. |
subscription.trial_will_end | Three days before a trial ends |
subscription.updated | Quantity, period or cancellation flag changed — or the customer scheduled or withdrew a pause (pause.scheduled: true) |
subscription.plan_changed | Plan changed; carries previous_plan, previous_quantity, proration { charged, credited, currency, payment } — the same object as the change_plan response — and changed_by: "merchant" (dashboard or API) or "customer" (the portal) |
subscription.past_due | A charge failed and dunning has started — a renewal, a trial ending with no card or a declined one, or the first charge of a subscription created through the API. failure_code is no_payment_method when there was no card. |
subscription.charge_failed | A dunning retry also failed |
subscription.recovered | Dunning worked — the subscription is active again; carries recovered_amount and its currency |
subscription.paused | Paused by the merchant; after the last retry failed, when the merchant chose pause (pause_reason: "payment_failed"); or by the customer from the portal, when their paid period ends (pause_reason: "customer_requested", and pause.resumes_at says when billing restarts) |
subscription.resumed | Resumed. One paused while owing money is past_due until the owed charge succeeds |
subscription.canceled | Canceled, by request or after the last retry failed (cancellation_details.canceled_by: "payment_failed") |
| Event | When it fires |
|---|---|
customer.created | A customer record was created |
customer.updated | Customer details changed |
customer.deleted | Personal data cleared, cards deactivated |
payment_method.attached | A card was saved (the token, never the number) |
payment_method.detached | A card was removed and deactivated at the bank |
checkout.session.completed | A checkout finished successfully |
checkout.session.expired | A checkout was abandoned and expired |
| Event | When it fires |
|---|---|
refund.created | A refund was recorded |
refund.succeeded | The bank accepted the refund |
refund.failed | The bank rejected the refund |
dispute.opened | A chargeback was raised |
dispute.closed | A chargeback was won or lost; carries status, amount and currency |
product.created | A product was created |
product.updated | A product changed |
product.archived | A product and its prices were archived |
plan.created | A price was created |
plan.updated | A price changed |
plan.archived | A price was archived |
coupon.created | A coupon was created |
coupon.archived | A coupon was archived |
discount.created | A coupon was applied to a subscription |
discount.removed | A discount was removed |
payment_link.created | A payment link was created |
payment_link.updated | A payment link changed |
invoice.finalized | An invoice to your customer was issued (this is the one to listen for) |
invoice.paid | An invoice to your customer was paid |
invoice.voided | An invoice was voided |
invoice.uncollectible | An invoice went unpaid past its grace period |
merchant.suspended | Live processing was suspended |
merchant.reinstated | Live processing was restored |