Webhooks
The verify-then-parse pattern, the raw-body rule, per-provider signature schemes, normalized events, and idempotency.
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.
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:
const body = await request.text(); // once
await verifyWebhook({ headers: request.headers, body, secret });
await parseWebhookEvent({ headers: request.headers, body });
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.
Signature schemes per provider
You never have to implement these — verifyWebhook handles each transparently — but knowing which
secret goes where saves a lot of debugging:
| Provider | Header(s) | Signed payload | Key derivation | Digest |
|---|---|---|---|---|
| Polar | webhook-id, webhook-timestamp, webhook-signature |
{id}.{ts}.{body} |
secret verbatim, including whsec_ (UTF-8) |
base64, any v1, part |
| Dodo Payments | same (Standard Webhooks) | {id}.{ts}.{body} |
strip whsec_, then base64-decode into key bytes |
base64, any v1, part |
| Stripe | stripe-signature |
{t}.{body} |
secret verbatim, including whsec_ |
lowercase hex, any v1= (v0= ignored) |
| Paddle | paddle-signature (ts=…;h1=…) |
{ts}:{body} |
secret verbatim | lowercase hex, any h1= |
| Lemon Squeezy | x-signature |
body only (no timestamp) | secret verbatim | hex, compared case-insensitively |
Polar, Dodo Payments, Stripe, and Paddle deliveries carry a timestamp and are rejected outside a 300-second tolerance, which bounds replay attacks. Lemon Squeezy sends no timestamp, so replay protection has to come from your own 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'
| 'unknown';
type?WebhookEventType
The normalized event type.
WebhookEventTypeproviderType?string
The provider's original event type string, always preserved.
stringsubscription?Subscription
Set on subscription.* events.
Subscriptionorder?Order
Set on order.paid.
Ordercheckout?Checkout
Set on checkout.completed (and on not-yet-complete checkout events, which report unknown).
Checkoutraw?unknown
The untouched provider envelope.
unknownWhat each type means
subscription.created— a new subscription exists. Dodo Payments has no such event; its first signal arrives assubscription.updated, so always upsert rather than insert.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. On Lemon Squeezy this covers bothorder_created(first payment) andsubscription_payment_success(renewals), since renewals never emit an order.checkout.completed— a checkout was paid. Only Polar and Stripe expose a checkout event the SDK can confirm as paid.unknown— everything else. Never an error.
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;
case 'checkout.completed':
await fulfill(event.checkout!);
break;
default:
// 'unknown' — log providerType and move on.
break;
}
The full per-provider event matrix lives in the webhook events reference.
Routing a shared endpoint
detectWebhookProvider — exported from the package root — identifies the sender of a delivery so one
endpoint can serve several providers:
import { detectWebhookProvider, type ProviderName } from 'revenue-sdk';
import * as polar from 'revenue-sdk/polar';
import * as stripe from 'revenue-sdk/stripe';
type WebhookHelpers = Pick<typeof polar, 'verifyWebhook' | 'parseWebhookEvent'>;
const handlers: Partial<Record<ProviderName, WebhookHelpers>> = { polar, stripe };
export async function POST(request: Request): Promise<Response> {
const headers = request.headers;
const body = await request.text();
const provider = await detectWebhookProvider({ headers, body });
const helpers = provider === undefined ? undefined : handlers[provider];
if (helpers === undefined) {
return new Response('unknown sender', { status: 400 });
}
const secret = secretFor(provider);
if (!(await helpers.verifyWebhook({ headers, body, secret }))) {
return new Response('invalid signature', { status: 401 });
}
const event = await helpers.parseWebhookEvent({ headers, body });
// ...
return new Response(null, { status: 204 });
}
Detection is header-based: stripe-signature → Stripe, paddle-signature → Paddle, x-signature →
Lemon Squeezy. Polar and Dodo Payments share the Standard Webhooks headers, so the JSON body is
inspected for business_id (present only on Dodo Payments) — which is why the helper is async.
A full Cloudflare Worker handler
import { parseWebhookEvent, verifyWebhook } from 'revenue-sdk/polar';
interface Env {
POLAR_WEBHOOK_SECRET: string;
PROCESSED: KVNamespace;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
if (request.method !== 'POST') {
return new Response('method not allowed', { status: 405 });
}
const headers = request.headers;
const body = await request.text();
if (!(await verifyWebhook({ headers, body, secret: env.POLAR_WEBHOOK_SECRET }))) {
return new Response('invalid signature', { status: 401 });
}
// Standard Webhooks: webhook-id is the delivery id.
const deliveryId = headers.get('webhook-id');
if (deliveryId && (await env.PROCESSED.get(deliveryId)) !== null) {
return new Response(null, { status: 204 }); // already handled
}
const event = await parseWebhookEvent({ headers, body });
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;
}
if (deliveryId) {
await env.PROCESSED.put(deliveryId, '1', { expirationTtl: 60 * 60 * 24 * 3 });
}
return new Response(null, { status: 204 });
},
};
Idempotency
Every provider retries deliveries, and several send overlapping events for one state change. Assume at-least-once, out-of-order delivery:
-
Dedupe on the delivery ID. Store it with a TTL and drop repeats:
Provider Delivery ID Polar webhook-idheaderDodo Payments webhook-idheaderStripe idon the event envelope (raw.id)Paddle event_idon the envelope (raw.event_id)Lemon Squeezy no delivery ID — dedupe on (providerType, subscription/order id, updated timestamp) -
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 — see the webhook handler guide.