# Webhooks

Source: https://docs.ezoic.com/docs/subscriptions/webhooks/


Webhooks send subscription and payment events from Ezoic Subscriptions to a URL you choose. Use them to update your own systems when a reader checks out, starts or ends a trial, renews, cancels, or pays an invoice.

Events are signed with [Standard Webhooks](https://www.standardwebhooks.com/) so your server can confirm they came from Ezoic.


Webhooks notify you that something happened. They do not grant or remove access on your site. To check what a reader can access right now, use [`hasAccess(...)`](/docs/subscriptions/publisher-managed-access/) or the [REST API](/docs/subscriptions/rest-api/).


## Permissions

Webhooks are managed under the **Settings** permission for Subscriptions:

| Access | Allows |
|---|---|
| Settings read | View destinations, their full URLs, and delivery history |
| Settings write | Create, edit, enable, disable, and delete destinations; reveal or rotate the signing secret; retry or resend deliveries |

## Live and Test Destinations

Live mode and test mode have separate destinations. A live destination receives only live events. A test destination receives only events from [test mode](/docs/subscriptions/testing-sandbox/) activity, such as Stripe test-card checkouts. The **Live mode | Test mode** toggle on the **Webhooks** page selects which set you are viewing and editing.

Every payload has a `livemode` field: `true` for live events, `false` for test events.

## Add a Destination

1. In your Ezoic dashboard, open **Subscriptions → Settings** and click **Manage webhooks**.
2. Choose **Live mode** or **Test mode**.
3. Click **Add destination**, enter a name and URL, and select at least one event type.
4. Copy the signing secret shown after you create the destination, and store it on your server. You can reveal it again later from the destination's page.

The URL must:

- Use `https://`.
- Use port 443 (the default HTTPS port).
- Resolve to a public internet address. Private, local, and internal addresses are rejected.
- Contain no username, password, or `#fragment`.

Any endpoint that meets these rules works, including your own server or an automation service such as Zapier.

Each site can have up to 10 destinations in live mode and 10 in test mode.

The dashboard shows each destination's full URL, including any query string. Anyone with Settings read access can see it, so do not rely on a secret token in the URL. Verify the signature instead.

A new destination only receives events that happen after it is created. Earlier events are never sent to it.

## Event Types

Each destination receives only the event types you select. A change to the selection applies to events not yet queued for delivery, which can include an event from a few seconds before the change. Deliveries that already exist are not changed.

| Event type | Sent when |
|---|---|
| `checkout.completed` | A checkout completes and Ezoic grants the purchase: a subscription, a one-time purchase, or a donation. |
| `subscription.trial_started` | A subscription starts a free trial, either at signup or by moving into a trial later. |
| `subscription.trial_ended` | A subscription leaves its free trial. It does not mean the reader paid. A trialing subscription that is canceled outright may send only `subscription.ended`. |
| `subscription.started` | A subscription begins its first paid term: a paid signup with no trial (including a 100% discount), or the first billing-cycle invoice after a trial, including a $0 one. Ending a trial early with an immediate charge sends `invoice.paid` but not `subscription.started`. Sent at most once per subscription. |
| `subscription.renewed` | A later billing cycle is paid, including a $0 renewal. Sent at most once per invoice. |
| `subscription.cancellation_scheduled` | A cancellation is scheduled, at the end of the billing period or on a set date. |
| `subscription.cancellation_reversed` | A scheduled cancellation is removed before the subscription ends. |
| `subscription.updated` | Another change: status (for example `active` to `past_due`), trial dates, price or quantity, currency, collection method, or a change to the timing of a scheduled cancellation. |
| `subscription.ended` | A subscription ends. Sent at most once per subscription. |
| `invoice.paid` | An invoice is fully paid. This includes $0, discounted, and trial-signup invoices. |
| `invoice.payment_failed` | A payment attempt on an invoice fails. One invoice can fail more than once, and each failure is a separate event. |

Subscription events cover subscription-level free trials only. Refunds, disputes, expired checkouts, and failed one-time payments that never produced an invoice have no events.

### One Purchase, Several Events

A single action can produce several events, each with its own `id`:

- A paid subscription checkout sends `checkout.completed`, `invoice.paid`, and `subscription.started`.
- A trial checkout sends `checkout.completed` and `subscription.trial_started`, and can also send `invoice.paid` for a $0 invoice. `subscription.started` follows when the first billing-cycle invoice after the trial is paid.
- A paid renewal sends `invoice.paid` and `subscription.renewed`.
- A cancellation at period end sends `subscription.cancellation_scheduled` now and `subscription.ended` when the subscription ends.

Pick one event type per question so you do not count a sale twice: for example, `checkout.completed` for new purchases, `subscription.renewed` for renewals, or `invoice.paid` for every paid invoice. Do not add `invoice.paid` to `subscription.renewed`, or `checkout.completed` to `subscription.started`.

## Payload

Each request is a `POST` with a JSON body:

```json
{
  "id": "2f7c1a4e-0b1d-4c5e-8a9f-000000000136",
  "type": "checkout.completed",
  "schemaVersion": 1,
  "livemode": true,
  "created": 1798200005,
  "subject": {
    "type": "checkout_session",
    "id": "cs_41"
  },
  "data": {
    "checkout": {
      "checkoutSessionId": "cs_41",
      "subscriptionId": "sub_41",
      "paymentStatus": "paid",
      "amountTotal": 999,
      "currency": "usd",
      "paymentIntentId": null,
      "invoiceId": "in_41",
      "lines": [
        {
          "productId": "prod_41",
          "priceId": "price_41",
          "quantity": 1,
          "ezoicProductId": "ext-prod-41",
          "ezoicPriceId": "ext-price-410",
          "productKey": "product-41",
          "priceKey": "price-410",
          "productType": "access",
          "item": null
        }
      ]
    },
    "customer": {
      "id": "cus_41",
      "email": "member41@example.com"
    }
  }
}
```

| Field | Description |
|---|---|
| `id` | Unique event ID. It stays the same on every retry and resend. |
| `type` | Event type from the table above. |
| `schemaVersion` | Payload version, currently `1`. |
| `livemode` | `true` for live events, `false` for test events. |
| `created` | When the event happened, in Unix seconds. Not when it was delivered. |
| `subject` | `type` and `id` of the object the event is about: `subscription`, `invoice`, or `checkout_session`. A checkout without a Checkout Session uses its subscription. |
| `data` | The event object (`subscription`, `invoice`, or `checkout`) plus `customer`. |

General rules:

- Timestamps are Unix seconds. Amounts are integers in the currency's smallest unit (for example cents), copied from Stripe after discounts. Currency codes are lowercase.
- Every documented field is present. Fields are `null` only when they have no value; lists are never `null`.
- `customer.id` is the Stripe customer ID. One reader can have more than one. `customer.email` is the customer's email when Ezoic prepared the event, normally seconds after it happened. It is `null` only if the Stripe customer was deleted.
- Stripe IDs (`sub_…`, `in_…`, `prod_…`, `price_…`) appear as-is. `ezoicProductId` and `ezoicPriceId` are the stable Ezoic IDs for your products and prices; match on these. `productKey` and `priceKey` are the [handles](/docs/subscriptions/products/) when the event was prepared; handles can change, so do not match on them.
- In subscription and invoice events, the Ezoic fields (`ezoicProductId`, `ezoicPriceId`, `productKey`, `priceKey`, `productType`) are `null` for items that are not Ezoic products, such as items added directly in Stripe. Checkout lines always have them.
- Ignore fields and event types you do not recognize. New ones can be added without a new `schemaVersion`. Status values copied from Stripe, such as `status` and `collectionMethod`, can include values Stripe adds later.

### Subscription Events

`data.subscription` contains `id`, `status`, `currency`, `collectionMethod`, `created`, `trialStart`, `trialEnd`, `canceledAt`, `endedAt`, `cancelAtPeriodEnd`, `cancelAt`, and `items`. Each item has `id`, `productId`, `priceId`, `quantity`, `currentPeriodStart`, `currentPeriodEnd`, `ezoicProductId`, `ezoicPriceId`, `productKey`, `priceKey`, and `productType` (`access`, `donation`, or `null`).

For `subscription.started` and `subscription.renewed`, the subscription is read shortly after the payment, so it can already show a later change, such as a cancellation.

### Invoice Events

`data.invoice` contains `id`, `status`, `subscriptionId` (`null` for an invoice without a subscription), `amountDue`, `amountPaid`, `amountRemaining`, `currency`, `created`, and `lines`. Each line has `id`, `productId`, `priceId`, `quantity`, `ezoicProductId`, `ezoicPriceId`, `productKey`, `priceKey`, and `productType`. Lines identify what was billed; they have no per-line amounts.

### Checkout Events

`data.checkout` contains:

| Field | Description |
|---|---|
| `checkoutSessionId` | Stripe Checkout Session ID, or `null` when the checkout created the subscription directly. |
| `subscriptionId` | Stripe subscription ID for subscription checkouts, otherwise `null`. |
| `paymentStatus` | `paid` (including $0) or `no_payment_required` (for example a trial). |
| `amountTotal`, `currency` | Total of the checkout's invoice. |
| `paymentIntentId` | Stripe PaymentIntent ID for paid one-time purchases, otherwise `null`. |
| `invoiceId` | Stripe invoice ID. For a trial this can be the $0 trial invoice. |
| `lines` | What was purchased: `productId`, `priceId`, `quantity`, `ezoicProductId`, `ezoicPriceId`, `productKey`, `priceKey`, `productType`, and `item`. `item` is the item key for a one-time purchase of a single item, otherwise `null`. |

`checkout.completed` records that the purchase completed. It does not mean the reader still has access later.

## Verify Signatures

Every request includes three headers:

| Header | Value |
|---|---|
| `webhook-id` | The event `id`. |
| `webhook-timestamp` | When this attempt was sent, in Unix seconds. New on every attempt. |
| `webhook-signature` | One or more space-separated signatures, each `v1,<base64>`. |

The signature is an HMAC-SHA256 of `webhook-id`, `webhook-timestamp`, and the exact request body, joined with `.`, using your signing secret. The secret starts with `whsec_`; the rest is the base64-encoded key.

To verify:

1. Read the **raw** request body. Verify before parsing the JSON. Parsing and re-serializing changes the bytes and the signature will not match.
2. Reject requests whose `webhook-timestamp` is more than 5 minutes from your server's clock.
3. Accept the request if any signature in `webhook-signature` matches.

This Node.js example uses Express and the built-in `crypto` module:

```javascript
import crypto from "node:crypto";
import express from "express";

const TOLERANCE_SECONDS = 5 * 60;

function verifyWebhook(rawBody, headers, secret) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signatures = headers["webhook-signature"];
  if (!id || !timestamp || !signatures) {
    throw new Error("missing webhook headers");
  }
  if (!/^\d+$/.test(timestamp)) {
    throw new Error("invalid timestamp");
  }
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) {
    throw new Error("timestamp outside tolerance");
  }

  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = crypto
    .createHmac("sha256", key)
    .update(`${id}.${timestamp}.`)
    .update(rawBody)
    .digest();

  for (const entry of signatures.split(" ")) {
    const [version, value] = entry.split(",");
    if (version !== "v1" || !value) {
      continue;
    }
    const received = Buffer.from(value, "base64");
    if (received.length === expected.length && crypto.timingSafeEqual(received, expected)) {
      return JSON.parse(rawBody.toString("utf8"));
    }
  }
  throw new Error("no matching signature");
}

const app = express();

// express.raw keeps the body as the exact bytes Ezoic signed.
app.post("/ezoic-webhooks", express.raw({ type: "application/json" }), (req, res) => {
  let event;
  try {
    event = verifyWebhook(req.body, req.headers, process.env.EZOIC_WEBHOOK_SECRET);
  } catch (err) {
    res.status(400).send(err.message);
    return;
  }

  // Skip event.id values you have already processed, then queue slow work.
  console.log(event.id, event.type);
  res.sendStatus(204);
});

app.listen(3000);
```

You can also use an official [Standard Webhooks library](https://github.com/standard-webhooks/standard-webhooks/tree/main/libraries) for your language. Pass it your signing secret and the raw body.

## Rotate the Signing Secret

Rotating creates a new secret. For the next 24 hours, each request carries two signatures: one with the new secret and one with the old secret. Requests verify with either, so you can update your server any time in that window. After 24 hours, only the new secret signs.

**Reveal** always shows the current (newest) secret. You cannot rotate again until the 24-hour overlap ends.

## Respond to Deliveries

Return any `2xx` status to acknowledge the event. Ezoic ignores the response body.

- A `2xx` only means your endpoint received the event. Ezoic does not know whether your later processing succeeded.
- Respond within 20 seconds. Acknowledge first and do slow work in the background.
- Redirects are not followed. A `3xx` response counts as a failure.
- Any other status, a timeout, or a connection error counts as a failure and is retried.
- `410 Gone` disables the destination. No further events are sent until you enable it again. A `410` from a URL you have since changed only causes a retry.

### Duplicates and Order

- Events are delivered **at least once**. The same event can arrive more than once. Use `id` to skip events you already processed.
- Events can arrive **in any order**. `created` is when the event happened, but do not treat it or `id` as a version number. To know a subscription's current state, check it with the [REST API](/docs/subscriptions/rest-api/) instead of relying on the last event received.

### Retry Schedule

After a failed attempt, Ezoic waits about 10 minutes, then 30 minutes, 2 hours, 6 hours, 12 hours, and 24 hours between attempts, and makes a final attempt about 72 hours after the first. Each wait varies by up to 10%. If a `429` or `503` response includes a `Retry-After` header, the next attempt waits at least that long, as long as it stays within the 72 hours.

If every attempt fails, the delivery is marked **Failed** and automatic retries stop. You can still retry it manually.

## Edit, Disable, or Delete a Destination

- **Change endpoint URL:** the new URL applies to every later attempt, including retries of earlier events. The signing secret does not change. Delivery history shows the URL each attempt was sent to.
- **Disable:** stops all automatic sends, including pending retries. Deliveries stopped this way show as canceled. Enabling the destination again does not send missed events; retry the ones you need.
- **Delete:** cancels pending deliveries. They cannot be retried.

A request already in progress when you disable or delete a destination can still complete.

## Delivery History, Retry, and Resend

The **Event deliveries** tab shows each event sent to your destinations, its status, and every attempt with its URL, response status, and the first 4 KB of your response body. Open a destination and choose **View all** under **Recent deliveries** to see only its deliveries.

You can send a delivery again:

- **Retry** is available until the event has been delivered successfully.
- **Resend** is available after a successful delivery.

Both send the same payload with the same `id` and start a new retry schedule. A receiver that skips duplicate IDs will ignore a resend of an event it already processed.

Retry and resend are unavailable when:

- The destination is disabled. Enable it first.
- The destination was deleted.
- Ezoic recorded the event more than 90 days ago. Older events are marked archived: their history stays visible, but they cannot be sent again. After 90 days, no further attempts are made, including retries already scheduled.
- A delivery is already in progress.

## Test Your Endpoint

There is no button that sends a sample event. To test:

1. Create a destination in **Test mode**.
2. Generate real test activity, such as a test checkout in [test mode](/docs/subscriptions/testing-sandbox/).
3. Check the **Event deliveries** tab. Use **Resend** to send the same event again while you work on your endpoint.

