Skip to content
revenue-sdk
Esc
navigateopen⌘Jpreview
On this page

Webhooks

The verify-then-parse pattern, the raw-body rule, the normalized event union, and how to dedupe redelivered webhook events.

Webhooks are the only trustworthy source of billing truth: the redirect after checkout can be closed, retried, or forged, but a signed webhook cannot. revenue-sdk ships two standalone helpers per provider — verifyWebhook and parseWebhookEvent — that take a Web-standard Request and need no client.

Verify, then parse

Always in that order. parseWebhookEvent does not verify anything; it will happily parse a forged payload.

import { parseWebhookEvent, verifyWebhook } from 'revenue-sdk/stripe';

export async function POST(request: Request): Promise<Response> {
  const headers = request.headers;
  const body = await request.text();

  const valid = await verifyWebhook({ headers, body, secret: process.env.STRIPE_WEBHOOK_SECRET! });
  if (!valid) {
    return new Response('invalid signature', { status: 401 });
  }

  const event = await parseWebhookEvent({ headers, body });
  // ...
  return new Response(null, { status: 204 });
}

Both helpers accept either shape:

PropType
request?Request

A Web-standard Request. Provide this or headers + body.

TypeRequest
headers?Headers | Record<string, string>

Request headers, when you pass the body separately. Names are matched case-insensitively.

TypeHeaders | Record<string, string>
body?string

The raw request body — required with headers.

Typestring
secret?string

The endpoint signing secret. verifyWebhook only.

Typestring

verifyWebhook returns false — it never throws — for a missing header, a stale timestamp, a malformed secret, or a mismatched signature. Comparisons are constant-time.

You never have to implement a signature scheme yourself; the per-provider headers, signed payloads, key derivations and timestamp tolerances are documented in Webhook events. One consequence is worth carrying around: Lemon Squeezy signs no timestamp, so replay protection there comes entirely from de-duplication.

The raw-body rule

Signatures are computed over the exact bytes the provider sent. Any re-serialization (parsing JSON and stringifying it again, a body-parser middleware, a proxy that reformats) breaks verification.

Read the body once with await request.text() and pass that same string to both helpers, as the snippet above does. Passing { request } also works — the helpers clone the request internally — but reading the text once is cheaper and makes the raw body available for logging and de-duplication.

Normalized events

parseWebhookEvent maps the provider payload onto a small closed set:

type WebhookEventType =
  | 'subscription.created'
  | 'subscription.updated'
  | 'subscription.canceled'
  | 'order.paid'
  | 'checkout.completed'
  | 'license.issued'
  | 'unknown';

WebhookEvent is a discriminated union on type. Narrowing it — a switch, an if — hands you exactly the payload that event carries, as a required field:

if (event.type === 'order.paid') {
  await recordPayment(event.order); // `order` is required here, no `!` needed
}

Every member carries the same base fields:

PropType
type?WebhookEventType

The normalized event type — the discriminant.

TypeWebhookEventType
providerType?string

The provider's original event type string, always preserved.

Typestring
idempotencyKey?string

Provider-namespaced dedupe key (<provider>:<id>). Always set, stable across retries and dashboard replays.

Typestring
createdAt?Date

When the event occurred, taken from the provider's envelope — never from the delivery header, which moves on every retry. Absent on Lemon Squeezy, which sends none.

TypeDate
raw?unknown

The untouched provider envelope.

Typeunknown

and adds the payload its own type is defined by — subscription, order, checkout, or licenseKeyId + licenseKey. Which model lands on which event is tabulated in Webhook events.

Each member is exported under its own name — SubscriptionUpdatedEvent, OrderPaidEvent, LicenseIssuedEvent, … — for typing a handler that takes one kind of event, and WebhookEventBase for the shared fields.

What each type means

  • subscription.created — a new subscription exists.
  • subscription.updated — anything changed: activation, renewal, plan change, pause, resume, past-due, and a scheduled cancellation. This is the workhorse.
  • subscription.canceledterminal only. The subscription has actually ended.
  • order.paid — money was received, including renewals, and it fires once per payment. The attached Order is fully mapped and its id is the identifier client.orders.get takes — see Orders.
  • checkout.completed — a checkout was paid, and paid means the money arrived.
  • license.issued — a license key was issued to a customer. licenseKeyId is always set, licenseKey only where the provider sends the key itself — see License keys.
  • unknown — everything else. Never an error.
Per-provider gaps behind these definitions
  • Dodo Payments has no subscription.created. Its first signal for a new subscription arrives as subscription.updated, so always upsert rather than insert.
  • Only Polar and Stripe emit a checkout event the SDK can confirm as paid. Elsewhere, checkout.completed never fires; use order.paid.
  • order.paid on Lemon Squeezy covers two provider events. A renewal never raises an order, so subscription_payment_success maps to order.paid too — except for the initial invoice, which is the same money as the order_created that already fired and stays unknown.
  • license.issued exists on Polar, Lemon Squeezy and Dodo Payments only, and only for issuance; revocation and updates are not normalized.

The complete per-provider event matrix is in Webhook events.

The transition the provider named

Alongside type, an event reports which lifecycle transition the provider’s own event string named — and leaves the field undefined where the provider names none:

type SubscriptionChange = 'cancel_scheduled' | 'past_due' | 'paused' | 'resumed' | 'uncanceled';

This is a sibling field, not extra WebhookEventType members, so a switch written before it existed keeps compiling and keeps routing the same events. It is derived only from the provider’s event string, never from payload statecancelAtPeriodEnd stays true for the rest of the period, so a payload-derived transition would re-announce the same scheduled cancellation on every later event. Coverage is uneven by design; the matrix and the traps it encodes are in Webhook events.

Unknown events never throw

Provider event catalogs grow. Anything outside the mapped set comes back as { type: 'unknown', providerType, raw } so a new provider event can never crash your endpoint. The one case that does throw is an unparseable body — a RevenueError with code validation.

switch (event.type) {
  case 'subscription.created':
  case 'subscription.updated':
  case 'subscription.canceled':
    await upsertSubscription(event.subscription);
    break;
  case 'order.paid':
    await recordPayment(event.order);
    break;
  default:
    // 'unknown' and the rest — log providerType and move on.
    break;
}

Routing a shared endpoint

detectWebhookProvider — exported from the package root — identifies the sender of a delivery so one endpoint can serve several providers. Detection is header-based, falling back to inspecting the JSON body to tell Polar and Dodo Payments apart, which is why the helper is async. The dispatch code is in the webhook handler guide and the detection rules in Webhook events.

Idempotency

Every provider retries deliveries, and several send overlapping events for one state change. Assume at-least-once, out-of-order delivery:

  1. Dedupe on event.idempotencyKey. Every parsed event carries one — required, never empty, and stable across the provider’s retries and dashboard replays. Store it with a TTL and drop repeats:

    const event = await parseWebhookEvent({ headers, body });
    if ((await kv.get(event.idempotencyKey)) !== null) {
      return new Response(null, { status: 204 });
    }
  2. Upsert, never insert. Key subscription state on event.subscription.id. Reordered events then converge instead of conflicting.

  3. Ignore stale writes. Compare against a monotonic field (the provider’s updated_at in raw, or currentPeriodEnd) and skip anything older than what you have stored.

  4. Return 2xx fast. Acknowledge within a couple of seconds and move slow work off the request.

A complete handler that does all four — with persistence, status codes and safe logging — is in the webhook handler guide, and the Workers-specific version in Cloudflare Workers.

Where the key comes from

The key is <provider>:<id>, namespaced because one endpoint serving several providers is a supported setup — a bare id would only be safe next to an out-of-band provider column. Treat it as opaque and compare it for equality; nothing else about its shape is worth branching on.

The id each provider supplies
Provider Source Key
Polar webhook-id header polar:<id>
Dodo Payments webhook-id header dodo-payments:<id>
Stripe id on the envelope stripe:evt_…
Paddle event_id on the envelope paddle:evt_…
Lemon Squeezy SHA-256 of the raw body lemon-squeezy:sha256:<hex>

Paddle’s key is event_id, not notification_id. The latter identifies one delivery — a dashboard replay mints a new one — so it would defeat de-duplication entirely.

Lemon Squeezy publishes no event id at all, in headers or body, so the raw body is the only stable delivery identity. A redelivery is byte-identical and hashes the same; the cost is that two genuinely distinct deliveries with byte-identical bodies would be indistinguishable. Any provider whose id is missing falls back to the same sha256: form, so a derived key is always self-evident in a log.

Last updated on August 8, 2026

Was this page helpful?