Webhooks
The API sends a signed JSON POST to your endpoints when something changes: an account's plan, a usage limit, a payment, a cancellation.
Webhooks keep your app in step with changes made outside it: a teammate editing a plan in the dashboard, a PayPal renewal, a customer saved by a cancel flow. Add endpoints in the dashboard under Keys and webhooks, or through the API. To send events to other apps without code, see Zapier.
Add an endpoint
Each endpoint has a URL, a list of event types (or ["*"] for every type, including ones added later) and its own signing secret. An app can have up to 25 endpoints.
curl -X POST http://localhost:6767/apps/$APP_ID/webhooks \
-H "Authorization: Bearer $OFFER_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://blog.example.com/api/webhooks",
"events": ["checkout.completed", "cancel_flow.cancelled"]
}'{
"id": "whk_QwErTyNiOpAsDfGh",
"app_id": "app_AbCdEf",
"url": "https://blog.example.com/api/webhooks",
"description": null,
"events": ["checkout.completed", "cancel_flow.cancelled"],
"enabled": true,
"source": "custom",
"secret": "whsec_…",
"disabled_reason": null,
"created_at": "2026-10-01T12:00:00.000Z",
"updated_at": "2026-10-01T12:00:00.000Z",
"stats": { "total": 0, "succeeded": 0, "failed": 0, "pending": 0, "last_delivery_at": null, "last_status": null, "last_response_status": null }
}Save the secret where your receiver can read it. URLs must point to a public address: localhost, private networks and link-local hosts are rejected when you save the endpoint, and again at send time if the host resolves to one. Local development sets WEBHOOKS_ALLOW_PRIVATE_URLS=true on the API so receivers on your machine work. source is custom by default, or zapier for endpoints Zapier creates.
The request
Every event is a POST with the same envelope. data depends on the event type. Events about a record carry it as data.object. Update events add data.previous with the old values of the fields that changed.
POST /api/webhooks HTTP/1.1
Content-Type: application/json
User-Agent: OfferSDK-Webhooks/1.0
webhook-id: evt_kTnBwQzXyPcRfLmHsDaGqEoJ
webhook-timestamp: 1790856000
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
{
"id": "evt_kTnBwQzXyPcRfLmHsDaGqEoJ",
"type": "account.plan_changed",
"created_at": "2026-10-01T12:00:00.000Z",
"app_id": "app_AbCdEf",
"data": {
"object": { "id": "user_42", "app_id": "app_AbCdEf", "name": "Ada", "plan": "pro", "incentive": null, "created_at": "2026-09-01T09:30:00.000Z" },
"previous": { "plan": "free" }
}
}| Field | Description |
|---|---|
| id | The event id (evt_…). Also sent as webhook-id, and the same on every retry. |
| type | The event type. |
| created_at | When the event happened, as an ISO 8601 timestamp. |
| app_id | The app the event belongs to. |
| data | The event's payload. See the catalog below. |
| test | Present and true only on test sends. |
Verify the signature
Signatures follow Standard Webhooks. Each request carries three headers:
| Header | Value |
|---|---|
| webhook-id | The event id. |
| webhook-timestamp | Unix seconds when this attempt was sent. |
| webhook-signature | v1, followed by the base64 HMAC-SHA256 of <id>.<timestamp>.<body>, keyed with the base64-decoded part of the secret after whsec_. |
Verify against the raw body, before parsing it. The standardwebhooks package does the work. A minimal receiver in a Next.js route handler:
// app/api/webhooks/route.ts
import { Webhook, WebhookVerificationError } from "standardwebhooks";
const SECRET = process.env.OFFER_WEBHOOK_SECRET!; // "whsec_…"
export async function POST(req: Request) {
const body = await req.text(); // the raw body, before any JSON parsing
const headers = Object.fromEntries(req.headers);
let event: { id: string; type: string; data: Record<string, unknown> };
try {
event = new Webhook(SECRET.slice("whsec_".length)).verify(body, headers) as typeof event;
} catch (err) {
if (err instanceof WebhookVerificationError) return Response.json({ error: err.message }, { status: 401 });
throw err;
}
// Retries reuse the event id, so skip ids you've already handled.
if (await alreadyHandled(event.id)) return Response.json({ received: true });
switch (event.type) {
case "checkout.completed":
// link data.account.id to your user, send a receipt…
break;
case "cancel_flow.cancelled":
// stop billing you run outside PayPal…
break;
}
return Response.json({ received: true });
}Without a library, it is a few lines of Node:
import { createHmac, timingSafeEqual } from "node:crypto";
/** Standard Webhooks verification without a library. */
export function verifyOfferWebhook(secret: string, headers: Headers, body: string, toleranceSeconds = 300): boolean {
const id = headers.get("webhook-id");
const timestamp = headers.get("webhook-timestamp");
const signatures = headers.get("webhook-signature");
if (!id || !timestamp || !signatures) return false;
// Reject old requests, so a captured one can't be replayed later.
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = Buffer.from(createHmac("sha256", key).update(`${id}.${timestamp}.${body}`).digest("base64"));
// The header can hold several space-separated "v1,<signature>" entries.
return signatures.split(" ").some((entry) => {
const [version, signature] = entry.split(",");
const actual = Buffer.from(signature ?? "");
return version === "v1" && actual.length === expected.length && timingSafeEqual(actual, expected);
});
} POST /apps/:appId/webhooks/:webhookId/secret/regenerate replaces it; the old secret stops working at once.Delivery and retries
A 2xx response within 10 seconds counts as delivered. Anything else is a failure, including a redirect, which isn't followed. After a failure, the API tries again on this schedule:
| Attempt | Sent |
|---|---|
| 1 | Right away |
| 2 | 1 minute after the first failure |
| 3 | 5 minutes later |
| 4 | 30 minutes later |
| 5 | 2 hours later |
| 6 | 8 hours later |
| 7 | 24 hours later. If it fails, the delivery is marked failed. |
- Deliveries go out in parallel, so events can arrive out of order. Use
created_atwhen order matters. - A retry can arrive after you've already handled the event. Store the
webhook-idand skip ids you've seen. - Answer quickly and do slow work afterwards. The first 1,024 characters of your response body are kept in the delivery log.
- Events and their deliveries are kept for 30 days.
410 Gone
Answer 410 to unsubscribe. The API marks that delivery failed and disables the endpoint with disabled_reason set to “Endpoint returned 410 Gone”. This is how Zapier removes a subscription. A disabled endpoint gets no new events, and deliveries still waiting for a retry fail with “Endpoint disabled”. Set enabled: true with PATCH /apps/:appId/webhooks/:webhookId to turn it back on.
Delivery log, tests and retries
GET /apps/:appId/webhooks/:webhookId/deliveries lists deliveries newest first, with each attempt's status, response and the exact payload. Filter with ?status=pending, succeeded or failed, and page with ?limit= (default 50). The dashboard shows the same log.
{
"id": "whd_pLmKjHgFdSaQwErTyIoPZxCb",
"endpoint_id": "whk_QwErTyNiOpAsDfGh",
"event_id": "evt_kTnBwQzXyPcRfLmHsDaGqEoJ",
"event_type": "account.plan_changed",
"status": "pending",
"attempts": 2,
"next_attempt_at": "2026-10-01T12:06:00.000Z",
"last_attempt_at": "2026-10-01T12:01:00.000Z",
"response_status": 500,
"response_body": "Internal Server Error",
"error": "HTTP 500",
"duration_ms": 182,
"test": false,
"created_at": "2026-10-01T12:00:00.000Z",
"payload": { "id": "evt_kTnBwQzXyPcRfLmHsDaGqEoJ", "type": "account.plan_changed", "…": "…" }
}POST …/deliveries/:deliveryId/retry sends a delivery again right away and returns the result. It is a single attempt: if it fails, the delivery stays failed with no further retries. On a disabled endpoint nothing is sent, and the delivery fails at once with “Endpoint disabled”. Only test events go out to a disabled endpoint.
POST /apps/:appId/webhooks/:webhookId/test sends a sample event of the type you name once, even if the endpoint is disabled, and returns the delivery. Without a type, it sends the endpoint's first subscribed type (account.created for *). Test events have "test": true and don't show up in the event log or the endpoint's stats.
curl -X POST http://localhost:6767/apps/$APP_ID/webhooks/$WEBHOOK_ID/test \
-H "Authorization: Bearer $OFFER_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{ "type": "checkout.completed" }'GET /apps/:appId/events is the app's event log for the last 30 days, newest first, whether or not any endpoint received them. Filter with ?type=.
Event catalog
GET /event-types returns the same catalog with a sample data for each type, and needs no key. Usage events fire once each time a count crosses the threshold, and the API only computes them when some endpoint subscribes to them.
Accounts
| Event | When it fires | data |
|---|---|---|
| account.created | An account was created, through the API or by a checkout from a buyer without one. | object |
| account.updated | Any change to an account. | object, previous |
| account.deleted | An account was deleted. | object |
| account.plan_changed | The account moved to a different plan. | object, previous.plan |
| account.incentive_applied | An incentive was applied, or replaced the previous one. | object, previous.incentive |
| account.incentive_removed | The account's incentive was removed. | object, previous.incentive |
Usage
| Event | When it fires | data |
|---|---|---|
| usage.limit_warning | An account crossed 80% of a usage limit. | object |
| usage.limit_reached | An account used up a limit. Its can is now false. | object |
Plans
| Event | When it fires | data |
|---|---|---|
| plan.created | A plan was added. | object |
| plan.updated | A plan's details, limits, add-ons, meta or pricing changed. | object, previous |
| plan.deleted | A plan was deleted. | object |
Incentives
| Event | When it fires | data |
|---|---|---|
| incentive.created | An incentive was created. | object |
| incentive.updated | An incentive's details, overrides or add-ons changed. | object, previous |
| incentive.deleted | An incentive was deleted. | object |
Billing
| Event | When it fires | data |
|---|---|---|
| checkout.completed | A buyer paid through PayPal and the purchase was applied. | object (the checkout), account |
| account.addon_granted | An account got an add-on of its own, for example from an order bump. | object, addon_id, source |
| subscription.renewed | PayPal charged a renewal. | object |
| subscription.cancelled | A PayPal subscription was cancelled or expired. The account moved to the free plan, if there is one. | object, previous.plan |
| subscription.paused | Billing was suspended for a few months, usually from a cancel flow. | object, previous.plan |
| subscription.resumed | A paused subscription started billing again and the account is back on its plan. | object, previous.plan |
Retention
| Event | When it fires | data |
|---|---|---|
| cancel_flow.saved | A customer in a cancel flow accepted a save offer. | object (the session), account |
| cancel_flow.cancelled | A customer finished a cancel flow and cancelled. PayPal subscriptions are already cancelled; cancel other billing here. | object (the session), account |
In account and subscription events, data.object is the account, including its subscription. A usage event's object describes the counter:
{
"object": {
"account_id": "user_42",
"entitlement_id": "posts",
"entitlement_name": "Posts",
"usage": 40,
"max": 50,
"left": 10,
"percent": 80
}
}