View as Markdown

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 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(...) or the 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 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:

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

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 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 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.
  3. Check the Event deliveries tab. Use Resend to send the same event again while you work on your endpoint.