Developer documentationDocsGet API keys

Webhooks

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.

The payload

{
  "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
    }
  }
}

Headers

HeaderMeaning
ronda-signaturet=<unix>,v1=<hmac> — HMAC-SHA256 of `${t}.${body}`
ronda-event-typeThe event type, for routing without parsing
ronda-delivery-idThe 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-modetest or live

Verifying a signature

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.

Retries

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.

Idempotency on your side

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.

Endpoint requirements

  • HTTPS, on a publicly resolvable address, port 80 or 443 — in test mode as in live. Ronda cannot reach 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.)
  • Private, loopback, link-local and cloud-metadata ranges are refused — both when you save the URL and again immediately before every delivery, so DNS cannot be re-pointed at an internal service afterwards.
  • Redirects are not followed.

Event catalogue

Payments

EventWhen it fires
payment.createdA payment intent was recorded, before the bank was called
payment.succeededMoney 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.failedThe bank declined. Check failure_code.
payment.requires_actionThe issuer forced a 3-D Secure step-up on a recurring charge
payment.refundedFully refunded
payment.partially_refundedPartly refunded
payment.settledThe bank settled the funds to the merchant; carries id, amount, currency and settled_at

Subscriptions

EventWhen it fires
subscription.createdA subscription exists; it may be trialing
subscription.activatedFirst 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_endThree days before a trial ends
subscription.updatedQuantity, period or cancellation flag changed — or the customer scheduled or withdrew a pause (pause.scheduled: true)
subscription.plan_changedPlan 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_dueA 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_failedA dunning retry also failed
subscription.recoveredDunning worked — the subscription is active again; carries recovered_amount and its currency
subscription.pausedPaused 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.resumedResumed. One paused while owing money is past_due until the owed charge succeeds
subscription.canceledCanceled, by request or after the last retry failed (cancellation_details.canceled_by: "payment_failed")

Customers, cards and checkout

EventWhen it fires
customer.createdA customer record was created
customer.updatedCustomer details changed
customer.deletedPersonal data cleared, cards deactivated
payment_method.attachedA card was saved (the token, never the number)
payment_method.detachedA card was removed and deactivated at the bank
checkout.session.completedA checkout finished successfully
checkout.session.expiredA checkout was abandoned and expired

Money and account

EventWhen it fires
refund.createdA refund was recorded
refund.succeededThe bank accepted the refund
refund.failedThe bank rejected the refund
dispute.openedA chargeback was raised
dispute.closedA chargeback was won or lost; carries status, amount and currency
product.createdA product was created
product.updatedA product changed
product.archivedA product and its prices were archived
plan.createdA price was created
plan.updatedA price changed
plan.archivedA price was archived
coupon.createdA coupon was created
coupon.archivedA coupon was archived
discount.createdA coupon was applied to a subscription
discount.removedA discount was removed
payment_link.createdA payment link was created
payment_link.updatedA payment link changed
invoice.finalizedAn invoice to your customer was issued (this is the one to listen for)
invoice.paidAn invoice to your customer was paid
invoice.voidedAn invoice was voided
invoice.uncollectibleAn invoice went unpaid past its grace period
merchant.suspendedLive processing was suspended
merchant.reinstatedLive processing was restored