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.
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
- In your Ezoic dashboard, open Subscriptions → Settings and click Manage webhooks.
- Choose Live mode or Test mode.
- Click Add destination, enter a name and URL, and select at least one event type.
- 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, andsubscription.started. - A trial checkout sends
checkout.completedandsubscription.trial_started, and can also sendinvoice.paidfor a $0 invoice.subscription.startedfollows when the first billing-cycle invoice after the trial is paid. - A paid renewal sends
invoice.paidandsubscription.renewed. - A cancellation at period end sends
subscription.cancellation_schedulednow andsubscription.endedwhen 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
nullonly when they have no value; lists are nevernull. customer.idis the Stripe customer ID. One reader can have more than one.customer.emailis the customer's email when Ezoic prepared the event, normally seconds after it happened. It isnullonly if the Stripe customer was deleted.- Stripe IDs (
sub_…,in_…,prod_…,price_…) appear as-is.ezoicProductIdandezoicPriceIdare the stable Ezoic IDs for your products and prices; match on these.productKeyandpriceKeyare 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) arenullfor 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 asstatusandcollectionMethod, 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:
- Read the raw request body. Verify before parsing the JSON. Parsing and re-serializing changes the bytes and the signature will not match.
- Reject requests whose
webhook-timestampis more than 5 minutes from your server's clock. - Accept the request if any signature in
webhook-signaturematches.
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
2xxonly 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
3xxresponse counts as a failure. - Any other status, a timeout, or a connection error counts as a failure and is retried.
410 Gonedisables the destination. No further events are sent until you enable it again. A410from 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
idto skip events you already processed. - Events can arrive in any order.
createdis when the event happened, but do not treat it oridas 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:
- Create a destination in Test mode.
- Generate real test activity, such as a test checkout in test mode.
- Check the Event deliveries tab. Use Resend to send the same event again while you work on your endpoint.