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:
request?Request
A Web-standard Request. Provide this or headers + body.
Requestheaders?Headers | Record<string, string>
Request headers, when you pass the body separately. Names are matched case-insensitively.
Headers | Record<string, string>body?string
The raw request body — required with headers.
stringsecret?string
The endpoint signing secret. verifyWebhook only.
stringverifyWebhook 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:
type?WebhookEventType
The normalized event type — the discriminant.
WebhookEventTypeproviderType?string
The provider's original event type string, always preserved.
stringidempotencyKey?string
Provider-namespaced dedupe key (<provider>:<id>). Always set, stable across retries and dashboard replays.
stringcreatedAt?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.
Dateraw?unknown
The untouched provider envelope.
unknownand 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.canceled— terminal only. The subscription has actually ended.order.paid— money was received, including renewals, and it fires once per payment. The attachedOrderis fully mapped and itsidis the identifierclient.orders.gettakes — see Orders.checkout.completed— a checkout was paid, and paid means the money arrived.license.issued— a license key was issued to a customer.licenseKeyIdis always set,licenseKeyonly 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 assubscription.updated, so always upsert rather than insert. - Only Polar and Stripe emit a checkout event the SDK can confirm as paid. Elsewhere,
checkout.completednever fires; useorder.paid. order.paidon Lemon Squeezy covers two provider events. A renewal never raises an order, sosubscription_payment_successmaps toorder.paidtoo — except for theinitialinvoice, which is the same money as theorder_createdthat already fired and staysunknown.license.issuedexists 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 state — cancelAtPeriodEnd 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:
-
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 }); } -
Upsert, never insert. Key subscription state on
event.subscription.id. Reordered events then converge instead of conflicting. -
Ignore stale writes. Compare against a monotonic field (the provider’s
updated_atinraw, orcurrentPeriodEnd) and skip anything older than what you have stored. -
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.