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"]
  }'
201 Created
{
  "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.

account.plan_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" }
  }
}
FieldDescription
idThe event id (evt_…). Also sent as webhook-id, and the same on every retry.
typeThe event type.
created_atWhen the event happened, as an ISO 8601 timestamp.
app_idThe app the event belongs to.
dataThe event's payload. See the catalog below.
testPresent and true only on test sends.

Verify the signature

Signatures follow Standard Webhooks. Each request carries three headers:

HeaderValue
webhook-idThe event id.
webhook-timestampUnix seconds when this attempt was sent.
webhook-signaturev1, 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
// 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:

lib/verify-webhook.ts
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);
  });
}
Answer 401 when verification fails. Anyone can POST to your URL. Act only on requests whose signature matches, and keep the secret on your server. 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:

AttemptSent
1Right away
21 minute after the first failure
35 minutes later
430 minutes later
52 hours later
68 hours later
724 hours later. If it fails, the delivery is marked failed.
  • Deliveries go out in parallel, so events can arrive out of order. Use created_at when order matters.
  • A retry can arrive after you've already handled the event. Store the webhook-id and 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.

A delivery waiting for its third attempt
{
  "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

EventWhen it firesdata
account.createdAn account was created, through the API or by a checkout from a buyer without one.object
account.updatedAny change to an account.object, previous
account.deletedAn account was deleted.object
account.plan_changedThe account moved to a different plan.object, previous.plan
account.incentive_appliedAn incentive was applied, or replaced the previous one.object, previous.incentive
account.incentive_removedThe account's incentive was removed.object, previous.incentive

Usage

EventWhen it firesdata
usage.limit_warningAn account crossed 80% of a usage limit.object
usage.limit_reachedAn account used up a limit. Its can is now false.object

Plans

EventWhen it firesdata
plan.createdA plan was added.object
plan.updatedA plan's details, limits, add-ons, meta or pricing changed.object, previous
plan.deletedA plan was deleted.object

Incentives

EventWhen it firesdata
incentive.createdAn incentive was created.object
incentive.updatedAn incentive's details, overrides or add-ons changed.object, previous
incentive.deletedAn incentive was deleted.object

Billing

EventWhen it firesdata
checkout.completedA buyer paid through PayPal and the purchase was applied.object (the checkout), account
account.addon_grantedAn account got an add-on of its own, for example from an order bump.object, addon_id, source
subscription.renewedPayPal charged a renewal.object
subscription.cancelledA PayPal subscription was cancelled or expired. The account moved to the free plan, if there is one.object, previous.plan
subscription.pausedBilling was suspended for a few months, usually from a cancel flow.object, previous.plan
subscription.resumedA paused subscription started billing again and the account is back on its plan.object, previous.plan

Retention

EventWhen it firesdata
cancel_flow.savedA customer in a cancel flow accepted a save offer.object (the session), account
cancel_flow.cancelledA 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:

usage.limit_warning
{
  "object": {
    "account_id": "user_42",
    "entitlement_id": "posts",
    "entitlement_name": "Posts",
    "usage": 40,
    "max": 50,
    "left": 10,
    "percent": 80
  }
}