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.
If you are implementing verification by hand rather than using these helpers, the blog post on verifying webhook signatures across all five providers walks through each scheme with working code.
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.