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

# Webhooks

> Receive and verify signed event notifications from Suby.fi.

Suby.fi sends signed HTTP `POST` webhooks to the endpoints you register via
[`POST /v3/webhook-endpoints`](/v3-beta/api-reference/overview).
Each endpoint has its own signing secret (`whsec_…`), shown once at creation and
on rotation.

## Managing endpoints

* **Create** — `POST /v3/webhook-endpoints` (returns the `whsec_…` secret once).
* **Subscribe to specific events** — set `enabledEvents`. An empty array subscribes to **all** events.
* **Rotate the secret** — `POST /v3/webhook-endpoints/:id/rotate-secret`.
* **Disable** — `PATCH` with `isActive: false`.

## Event types

Events are named `resource.action`.

### Payment events

| Event                        | When                                                                                                                                                                                   |
| ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `payment.authorized`         | Card payment authorized (not yet captured).                                                                                                                                            |
| `payment.succeeded`          | Payment succeeded (crypto confirmed on-chain, or card captured/settled).                                                                                                               |
| `payment.completed`          | Payment fully completed.                                                                                                                                                               |
| `payment.failed`             | Payment did not go through — covers both a technical failure and an issuer/bank decline. The payment's `status` distinguishes them: `FAILED` (technical) vs `DECLINED` (real refusal). |
| `payment.canceled`           | Authorized payment voided/canceled.                                                                                                                                                    |
| `payment.refunded`           | Payment fully refunded.                                                                                                                                                                |
| `payment.partially_refunded` | Payment partially refunded.                                                                                                                                                            |
| `payment.chargeback`         | A chargeback was opened.                                                                                                                                                               |
| `payment.settled`            | Funds settled to the merchant (card payouts).                                                                                                                                          |
| `checkout.succeeded`         | A hosted checkout session completed successfully.                                                                                                                                      |

### Subscription events

| Event                   | When                                                                                                                                                                                                                                |
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `subscription.created`  | Subscription **activated** — fires once the first cycle confirms and the subscription becomes `ACTIVE` (not at API-create time). You never receive a `created` for a subscription whose first charge is declined.                   |
| `subscription.renewed`  | A renewal cycle was charged (card off-session, or a crypto renewal deposit confirmed).                                                                                                                                              |
| `subscription.updated`  | The plan changed (upgrade/downgrade). Fires when the swap is **applied**: immediately for an upgrade (after the prorated delta charge), at the next renewal for a period-end downgrade. Diff `data.product` to detect the new plan. |
| `subscription.past_due` | A renewal failed and the subscription entered dunning — card: a soft decline is being retried; crypto: the cycle lapsed unpaid (grace period running).                                                                              |
| `subscription.canceled` | Subscription canceled (immediately, or at period end once the boundary is reached).                                                                                                                                                 |
| `subscription.expired`  | Subscription ended — card: hard decline or all retries exhausted; crypto: grace lapsed unpaid. Access is removed.                                                                                                                   |

Suby drives renewals — you never call the API for cycle N+1:

* **Card / Apple Pay / Google Pay** — re-charged off-session on a progressive
  smart-retry back-off (up to 8 attempts over 30 days). **Soft** declines
  (insufficient funds, network, velocity) are retried; **hard** declines
  (stolen/lost card, closed account) end the subscription immediately. The
  current dunning state is on the `Subscription` object (`renewalAttempt`,
  `nextRenewalAttemptAt`, `lastDeclineCategory`).
* **Crypto** — renewed via a per-cycle email linking to the product's checkout;
  the customer re-deposits and the same subscription rolls forward.

### Customer & payment-method events

| Event                     | When                                      |
| ------------------------- | ----------------------------------------- |
| `customer.created`        | A customer was created.                   |
| `customer.updated`        | A customer was updated.                   |
| `customer.deleted`        | A customer was deleted.                   |
| `payment_method.attached` | A payment method was saved to a customer. |
| `payment_method.detached` | A saved payment method was revoked.       |

### Payout events

| Event              | When                         |
| ------------------ | ---------------------------- |
| `payout.completed` | A merchant payout completed. |
| `payout.failed`    | A merchant payout failed.    |

## Event payload

Every delivery — for **all** event types — shares the same top-level envelope.
The event kind is on the envelope as `type`; the resource-specific fields are
under `data` (each `data` carries its own `object` discriminator).

| Field         | Description                                                           |
| ------------- | --------------------------------------------------------------------- |
| `id`          | Unique event id (`evt_…`). Stable across retries — use it to dedupe.  |
| `type`        | The event, dotted (e.g. `payment.succeeded`, `subscription.renewed`). |
| `createdAt`   | ISO-8601 timestamp of the event (not the delivery).                   |
| `api_version` | The version the endpoint was created against.                         |
| `livemode`    | `true` = live, `false` = sandbox.                                     |
| `data`        | The event object. Carries an `object` field; `status` is lowercase.   |

A **payment** event:

```json theme={null}
{
  "id": "evt_1a2b3c",
  "type": "payment.succeeded",
  "createdAt": "2026-07-13T20:38:06.690Z",
  "api_version": "2026-05-12",
  "livemode": true,
  "data": { "object": "payment", "id": "pay_…", "status": "completed", "…": "…" }
}
```

A **subscription** event (same envelope — only `type` and `data` differ):

```json theme={null}
{
  "id": "evt_4d5e6f",
  "type": "subscription.renewed",
  "createdAt": "2026-07-13T20:38:06.690Z",
  "api_version": "2026-05-12",
  "livemode": true,
  "data": {
    "object": "subscription",
    "id": "sub_…",
    "status": "active",
    "current_cycle": 2,
    "total_cycles": null,
    "cancel_at_period_end": false,
    "expires_at": "2026-08-13T20:38:06.690Z",
    "organization_id": "org_…",
    "customer": { "object": "customer", "id": "cus_…", "email": "…" },
    "product": { "object": "product", "id": "pro_…", "…": "…" }
  }
}
```

Branch on the envelope `type` (or the `X-Webhook-Event` header) — never on the
`data` shape alone.

## Delivery headers

Every delivery carries:

| Header                | Description                                |
| --------------------- | ------------------------------------------ |
| `X-Webhook-Event`     | The event type (e.g. `payment.succeeded`). |
| `X-Webhook-Timestamp` | Unix timestamp in **seconds**.             |
| `X-Webhook-Signature` | `v1=<hex HMAC-SHA256>`.                    |

## Verifying signatures

The signature is an HMAC-SHA256 of `` `${timestamp}.${rawBody}` `` keyed with
your endpoint secret. **Use the raw request body bytes** — do not re-serialize
the parsed JSON.

```typescript theme={null}
import crypto from "node:crypto";

function verifySubyWebhook(req, secret: string): boolean {
  const timestamp = req.headers["x-webhook-timestamp"] as string;
  const signature = req.headers["x-webhook-signature"] as string; // "v1=<hex>"
  const rawBody = req.rawBody; // Buffer/string of the exact received bytes

  // Reject stale deliveries (replay protection)
  const ageSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (ageSeconds > 300) return false;

  const expected =
    "v1=" +
    crypto
      .createHmac("sha256", secret)
      .update(`${timestamp}.${rawBody}`)
      .digest("hex");

  // Constant-time compare
  return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}
```

<Warning>
  Capture the **raw body** before any JSON middleware parses it (in Express, use
  `express.raw()` on the webhook route, or `verify` to stash `req.rawBody`).
  Reject any delivery whose timestamp is more than 5 minutes old.
</Warning>

## Responding

Return a `2xx` quickly (ideally \< 5s). Non-2xx responses are retried with
backoff. Make your handler **idempotent** — the same event may be delivered more
than once.

<a id="access-granting" />

## Recommended access-granting strategy

| Payment method | Grant access on                                | Revoke access on                                             |
| -------------- | ---------------------------------------------- | ------------------------------------------------------------ |
| **Card**       | `payment.authorized` (or `checkout.succeeded`) | `payment.failed`, `payment.refunded`, `subscription.expired` |
| **Crypto**     | `payment.succeeded`                            | `payment.failed`, `subscription.expired`                     |
