---
title: Outbound webhooks
description: Signed HTTP notifications to your servers when billing events happen, and how to verify them.
domain: webhook
status: shipped
visibility: public
last_verified: 2026-07-14
order: 20
---
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`.

```json
{
  "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:

1. 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.
2. Rebuild the signed string: the timestamp, a dot, then the raw body.
3. Compute the HMAC-SHA256 of that string with your signing secret and compare it to the `v1` value with a constant-time comparison.
4. 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:

```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.
