Outbound webhooks
Signed HTTP notifications to your servers when billing events happen, and how to verify them.
An outbound webhook tells your own servers when something happens in your billing account. You give us an HTTPS URL, pick the events you care about, and we send a signed JSON message to that URL every time one of those events happens.
Setting one up
Open Settings, then Webhooks, and add an endpoint. You give it a URL, an optional description, and the set of events it should receive. You can pick individual events or subscribe to everything with a single "all events" choice, which also covers events we add later.
When you create the endpoint we show you a signing secret once. Copy it then and keep it somewhere safe. It starts with whsec_. You use it to check that a delivery really came from us. If you lose it, rotate the secret to get a new one.
Plan limits
Each plan allows a number of active endpoints: Launch includes 2, Grow 25, and Scale is unlimited. Only active endpoints count. An endpoint you switch off keeps its configuration and delivery history but stops receiving events and no longer counts against the limit, so you can park an endpoint without deleting it. If you are already at your limit, activate a spare by turning another one off first, or upgrade for more.
If you downgrade to a plan with a lower limit while more endpoints are active than the new plan allows, we keep your oldest endpoints active and switch off the newest ones to fit. Nothing is deleted; turn the ones you want back on after upgrading.
What we send
Each delivery is an HTTP POST with a JSON body. The body is an envelope: a stable event id, the event type (for example invoice.paid), when it happened, the API version pinned on your endpoint, and a data object describing the affected resource. data always names the resource with object (its type) and id, and carries record, the resource's current top-level fields in the same shape the API returns them. Child collections aren't embedded. An invoice's line items or a subscription's items need a follow-up API call using data.id. Update events also include changes, the fields that changed. On a delete the row is gone, so record is left out and you get the object and id.
{
"id": "evt_9f8c...",
"type": "invoice.paid",
"occurred_at": "2026-07-12T09:20:00Z",
"api_version": "2026-05-01",
"org_id": "org_3a2b...",
"data": {
"object": "invoice",
"id": "inv_4b1a...",
"record": { "id": "inv_4b1a...", "status": "paid", "total": 4900, "currency": "USD" }
},
"actor": { "type": "system" }
}
Verifying a delivery
Anyone can POST to your URL, so check the signature before you trust a message. Every delivery carries a header named X-yRecurring-Signature:
X-yRecurring-Signature: t=1720800000,v1=5257a869e7...e4a1c9f0b2
The t is the time we sent it, in seconds. The v1 is the signature: the HMAC-SHA256 of the string made by joining the timestamp, a dot, and the exact bytes of the request body, keyed by your signing secret.
To verify a delivery:
- Read the raw request body as bytes, before any JSON parsing. If your web framework parses the body and re-serializes it, the bytes change and the check fails, so capture the raw body first.
- Rebuild the signed string: the timestamp, a dot, then the raw body.
- Compute the HMAC-SHA256 of that string with your signing secret and compare it to the
v1value with a constant-time comparison. - Reject the delivery if the timestamp is more than a few minutes old. That stops someone from capturing a real delivery and sending it again later.
Here is the check in Node.js:
const crypto = require("crypto");
const SECRET = process.env.YRECURRING_WEBHOOK_SECRET; // starts with whsec_
const TOLERANCE = 5 * 60; // seconds
function verify(header, rawBody) {
let t = null;
const sigs = [];
for (const part of header.split(",")) {
const eq = part.indexOf("=");
const k = part.slice(0, eq), v = part.slice(eq + 1);
if (k === "t") t = v;
else if (k === "v1") sigs.push(v);
}
if (!t) return false;
const age = Math.floor(Date.now() / 1000) - Number(t);
if (!Number.isFinite(age) || Math.abs(age) > TOLERANCE) return false;
const h = crypto.createHmac("sha256", SECRET);
h.update(t + ".");
h.update(rawBody); // the exact bytes we sent
const want = Buffer.from(h.digest("hex"));
return sigs.some((s) => {
const got = Buffer.from(s);
return got.length === want.length && crypto.timingSafeEqual(got, want);
});
}
The same steps work in any language: parse the header, rebuild the signed string, recompute the HMAC, and compare in constant time.
Rotating the secret
When you rotate an endpoint's secret, the old one keeps working for a short grace period so you can deploy the new one without missing deliveries. During that window we sign with both secrets, and the header carries more than one v1 value. Accept the delivery if any of them matches your current secret. The loop above already does this, so it keeps working across a rotation with no change.
Retries and replay
Return any 2xx status to accept a delivery. Anything else, including a timeout, counts as a failure and we try again with growing gaps between attempts: eight tries spread over about three days. After the last attempt the delivery is marked as given up. The endpoint's page lists recent deliveries with each one's latest status, attempt count, response code, and any error, and you can replay a delivery that has finished (delivered or given up).
Because we retry, the same event can reach you more than once. Make your handler safe to run twice: track the event id, which stays the same across retries and replays, and skip an id you have already processed.
Edge cases
- Verify the raw body, not the parsed one. Re-serialized JSON has different bytes and will not match the signature.
- Reject stale timestamps. A valid signature on an old delivery is still a replay; the timestamp check is what stops it.
- We do not follow redirects. If your URL returns a redirect we treat it as a failure rather than following it, so point the endpoint straight at your handler.
- Use HTTPS. We only accept public HTTPS endpoints. A private or internal address is rejected when you save it.