> ## Documentation Index
> Fetch the complete documentation index at: https://docs.withgale.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Subscription webhooks

> Get told when a subscription cycle is paid, a card fails, or a shopper's plan ends — without polling.

Get told when a subscription cycle is paid, a card fails, or a shopper's plan ends — without polling.

<Note>
  **Status: available.** Five subscription events, delivered to any endpoint you subscribe.
</Note>

## Why you need these

For a Gale-billed subscription, **Gale is the only system that knows what happened.** Gale holds the billing schedule, charges the stored card, and runs the retry policy when a payment fails.

That has a consequence worth being explicit about: on the Elements + PaymentIntent path there is no Stripe subscription object, so **Stripe will not notify you of a renewal.** Nor will your payment processor — it only ever sees a series of unrelated card charges. If you don't consume these webhooks, the only way to learn that a subscription renewed is to poll `GET /v2/subscriptions`.

For a physical-goods subscription that means nobody tells you to ship the next box.

## The events

| Event                         | Fires when                                                                          | What you'd typically do                                                |
| ----------------------------- | ----------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
| `subscription.created`        | A shopper starts a subscription at checkout                                         | Record the subscription against your customer                          |
| `subscription.renewed`        | **A cycle has been paid** — cycle 1 or any renewal                                  | Fulfil: ship the goods, extend access                                  |
| `subscription.payment_failed` | A cycle failed to charge                                                            | Decide whether to wait, chase the shopper, or pause access             |
| `subscription.canceled`       | The subscription ended                                                              | Stop fulfilment                                                        |
| `subscription.resumed`        | A scheduled cancellation was reversed, or a new card rescued a failing subscription | Resume fulfilment                                                      |
| `subscription.disputed`       | A cycle was disputed by the cardholder                                              | Branch on `stage` — a confirmed dispute means Gale has stopped billing |

**`subscription.renewed` is the fulfilment signal, and it covers the first cycle too.** We deliberately don't split sign-up from renewal into two events — you handle one, and every paid cycle arrives the same way. It fires only once the money is actually captured, not at authorisation, so it always means funds moved.

Two things worth knowing:

* **A scheduled cancellation does not fire `subscription.canceled`.** When a shopper cancels at period end, the subscription stays active and billable until that date. You get the event when it actually ends. Until then `cancel_at_period_end` is `true` on the subscription body.
* **Not every subscription emits every event.** If your subscription is scheduled by Stripe rather than by Gale, Gale never runs its renewal engine for it, so you'll see `created` / `canceled` / `resumed` but not `renewed` or `payment_failed` — Stripe reports those to you directly.

## Payload

Every event uses the same envelope:

```json theme={null}
{
  "id": "evt_a1b2c3d4e5f6g7h8",
  "type": "subscription.renewed",
  "created_at": "2026-08-20T09:14:22+00:00",
  "data": { }
}
```

`data` is the same subscription object you get from `GET /v2/subscriptions/{id}` — minus the `cycles` array, so the body stays small — plus `payment_method_type`:

```json theme={null}
{
  "subscription_id": "sub_01M08CTGDKJVWTMMRF1PK8ZQGM",
  "plan_id": "plan_01M08CTG7XQK4T0BJ4WM8ZP3RN",
  "status": "active",
  "cancel_at_period_end": false,
  "customer_email": "shopper@example.com",
  "amount": 4738,
  "currency": "USD",
  "interval": "month",
  "interval_count": 1,
  "next_charge_at": "2026-09-20T09:14:22+00:00",
  "current_period_end": "2026-09-20T09:14:22+00:00",
  "canceled_at": null,
  "card_brand": "visa",
  "card_last4": "4242",
  "payment_method_type": "CPM"
}
```

Amounts are integer **cents**, matching the rest of the API.

`plan_id` is the plan the subscription was created from, and it is **provenance, not a live pointer** — `amount`, `interval` and `interval_count` on the subscription stay authoritative, so editing a plan never reprices a subscription already running on it. It is `null` on a subscription created before plans existed.

### `subscription.renewed` adds `invoice`

```json theme={null}
"invoice": {
  "invoice_id": "si_01M0JW7Y018YCEC2D651VZ03Y0",
  "order_id": "ord_01M08CTFCREFQF7RFFG0NKXSR7",
  "period_start": "2026-08-20T09:14:22+00:00",
  "period_end": "2026-09-20T09:14:22+00:00",
  "amount_paid": 4738,
  "psp_reference": "NPSV6T7WDM6NXJV5"
}
```

`invoice_id` is the cycle's public id, the same value `GET /v2/subscriptions/{id}` returns for that cycle and the same field `subscription.disputed` carries — so you can fetch it directly at `GET /v2/subscriptions/{id}/invoices/{invoiceId}`.

`order_id` is a real Gale order — the same identifier the order and refund endpoints take, so you can reconcile a cycle against an order without a second lookup.

### `subscription.payment_failed` adds `failure`

```json theme={null}
"failure": {
  "disposition": "retry",
  "status": "past_due",
  "refusal_code": "51",
  "attempt": 1,
  "max_attempts": 3,
  "next_retry_at": "2026-08-22T09:14:22+00:00"
}
```

**Branch on `disposition`** — it tells you what Gale is going to do next, so you don't have to interpret raw decline codes:

| `disposition`     | What Gale does                                                                            | What you should do                                                                                      |
| ----------------- | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| `retry`           | Retries automatically on a widening schedule (`next_retry_at` tells you when)             | Usually nothing. Don't chase the shopper yet — an HSA payroll contribution often lands between attempts |
| `action_required` | Stops retrying — the card itself is the problem (expired, invalid, failed authentication) | Get the shopper to replace their card. Ask Gale for a card-update link                                  |
| `terminal`        | Never retries; the subscription goes unpaid                                               | Stop fulfilment. The card was blocked or the payment was refused outright                               |

`status` carries the resulting subscription state (`past_due`, `action_required`, or `unpaid`). Once retries are exhausted you'll receive a final `payment_failed` with `status: "unpaid"` — that's the end of the dunning cycle, and no further attempt is coming.

### `subscription.disputed` adds `dispute`

A cardholder disputed one of your cycles. One event covers the whole dispute
lifecycle — branch on `stage` rather than subscribing to several events.

```json theme={null}
"dispute": {
  "invoice_id": "si_01M0JW7Y018YCEC2D651VZ03Y0",
  "stage": "confirmed",
  "billing_held": true,
  "event_code": "CHARGEBACK",
  "amount": 4738,
  "currency": "USD",
  "reason": "fraudulent"
}
```

| `stage`        | What happened                                | `billing_held` | What you should do                                                  |
| -------------- | -------------------------------------------- | -------------- | ------------------------------------------------------------------- |
| `notification` | A dispute is opening. No money has moved yet | `false`        | Nothing is stopped. Gather evidence if you intend to contest        |
| `confirmed`    | The money has been taken back                | `true`         | **Stop fulfilment.** Gale has stopped billing this subscription     |
| `reversed`     | The dispute resolved in your favour          | `false`        | Resume fulfilment. Billing has already restarted — no action needed |

The subscription's `status` becomes **`disputed`** while the hold stands, and returns to exactly the status it held before once the dispute is reversed. That is the value you will see on `GET /v2/subscriptions` and in the `data` of this event.

**A confirmed dispute stops billing, and that is reversible.** Gale will not
charge the next cycle while a dispute stands, because charging a card whose
holder is disputing the last charge tends to produce more disputes. If the
dispute is later reversed, the subscription returns to exactly the state it was
in before — including a mid-dunning state — and renewals continue on the
original schedule. You don't have to call anything to resume it.

`invoice_id` identifies **which cycle** was disputed, and matches the
`invoice_id` on `GET /v2/subscriptions/{id}`, so you can reconcile it against
the cycle you fulfilled. `amount` is in minor units (cents).

`billing_held` is the field to branch on if you only care about the billing
consequence — `stage` tells you where the dispute is, `billing_held` tells you
what Gale did about it.

`event_code` is normally the card-network event as Adyen reported it. One value
is not: **`MANUAL_RELEASE`** means Gale support lifted the hold by hand, for the
case where a dispute was resolved outside the normal notification flow. It
arrives as `stage: "reversed"` with `billing_held: false`, because the billing
consequence is identical — billing has restarted. If you match on Adyen's codes,
treat anything you don't recognise as "read `billing_held`".

## Receiving them

### 1. Register your endpoint

Add it in the **Gale merchant dashboard** under Webhooks, choosing the subscription events you want. Endpoint registration isn't part of the merchant API — the dashboard (or your Gale contact) is where it happens.

You'll get a signing secret when the endpoint is created. Store it; you'll need it in step 2.

### 2. Verify the request came from Gale

Every delivery is signed. The headers you get:

| Header             | Purpose                                                                                                            |
| ------------------ | ------------------------------------------------------------------------------------------------------------------ |
| `X-Gale-Signature` | `t=<unix seconds>,v1=<hex HMAC-SHA256>` — verify this before trusting the body                                     |
| `X-Gale-Event`     | The event type, e.g. `subscription.renewed`                                                                        |
| `X-Gale-Delivery`  | Unique id for this delivery attempt, e.g. `wd_01J…`                                                                |
| `Authorization`    | `Bearer <your endpoint secret>` — **deprecated.** Still sent today, will be removed. Verify the signature instead. |

`v1` is `HMAC-SHA256` over `"<t>.<raw request body>"`, keyed with your endpoint secret.

<Warning>
  Verify against the **raw request body**, before any JSON parsing. If your framework parses and re-serialises the body, the bytes change and the signature will never match. In Express that means `express.raw()` on this route, not `express.json()`.
</Warning>

```js theme={null}
app.post('/hooks/gale', express.raw({ type: 'application/json' }), (req, res) => {
  const parts = (req.headers['x-gale-signature'] || '').split(',');
  const t = parts.find(p => p.startsWith('t='))?.slice(2);
  const sigs = parts.filter(p => p.startsWith('v1=')).map(p => p.slice(3));

  // Reject stale deliveries — 5 minutes is the window we recommend.
  if (!t || Math.abs(Date.now() / 1000 - Number(t)) > 300) return res.sendStatus(400);

  const expected = crypto
    .createHmac('sha256', process.env.GALE_WEBHOOK_SECRET)
    .update(`${t}.${req.body}`)          // req.body is a Buffer here — the raw bytes
    .digest('hex');

  const ok = sigs.some(s =>
    s.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected))
  );
  if (!ok) return res.sendStatus(401);

  // Acknowledge FIRST, then do the work.
  res.sendStatus(200);
  queue.push(JSON.parse(req.body));
});
```

Always compare in constant time (`timingSafeEqual`), and check the lengths first — `timingSafeEqual` throws on a length mismatch.

#### Why there can be more than one `v1`

While you're rotating your secret, we sign with **both** the new secret and the outgoing one for 24 hours. Accept the delivery if **any** `v1` matches. That's what lets you call `rotate-secret`, deploy the new secret at your own pace, and never drop a webhook in between.

#### The timestamp is what stops replays

`t` is inside the signed string, so an attacker can't shift it without breaking `v1`. Rejecting anything outside your window is therefore a real replay defence — but only outside the window. Inside it, dedupe on `X-Gale-Delivery` (see below).

<Note>
  Secrets issued after 20 August 2026 start with `whsec_`. The prefix is part of the key — pass the whole string to your HMAC. Older secrets keep working as-is.
</Note>

### 3. Respond quickly, then work

**Return a 2xx as soon as you've stored the event.** Any non-2xx is treated as a failure and the delivery is retried. Requests time out after **10 seconds** — do fulfilment work on your own queue, not inside the request.

### 4. Deduplicate

Retries mean you can receive the same event more than once, and a slow response that we time out may still have reached you. Both `id` (the `evt_…` in the body) and `X-Gale-Delivery` are unique — key your processing off one of them and ignore repeats.

Events can also arrive **out of order**. Don't infer state from arrival sequence; use `status` and `next_charge_at` in the body, which always reflect the subscription at the moment the event was raised.

## Retries

A failed delivery is attempted **5 times in total** — the first try plus four retries:

```
try → 1 min → 5 min → 30 min → 2 hours
```

After the last failure the delivery is marked `dead_letter` and abandoned. That gives you a window of about **2 h 36 min**: an outage shorter than that loses nothing, a longer one drops events, so reconcile with `GET /v2/subscriptions` after an extended incident.

Disabling or deleting an endpoint dead-letters anything still queued for it.

## Testing

Point an endpoint at a request-capture service, start a subscription in test mode, and you'll see `subscription.created` immediately and `subscription.renewed` once the sign-up charge captures. To exercise the failure path, use a card that produces a decline — the resulting `disposition` tells you which branch of your handler ran.

## Related resources

<CardGroup cols="2">
  <Card title="Custom Payment Methods" icon="map" href="/custom-payment-methods/overview">
    Which of the two Stripe integration paths fits.
  </Card>

  <Card title="Elements + PaymentIntent" icon="code" href="/custom-payment-methods/elements-payment-intent">
    Your backend hands off to Gale; Stripe is never in the payment path.
  </Card>

  <Card title="Subscription Plans" icon="tags" href="/subscriptions/plans">
    Attach a recurring price and cadence to your products.
  </Card>

  <Card title="Elements + Checkout Session" icon="plug" href="/custom-payment-methods/elements-checkout-session">
    Stripe orchestrates the charge and owns the subscription object.
  </Card>
</CardGroup>
