Production webhook handler
Build a production webhook endpoint — verify the signature, dedupe the delivery, upsert by subscription id, and beat the read-then-upsert race.
A billing webhook endpoint has four jobs, in this order: verify, dedupe, persist, and acknowledge fast. This guide builds one that does all four.
The shape of a correct handler
import { parseWebhookEvent, verifyWebhook } from 'revenue-sdk/polar';
export async function POST(request: Request): Promise<Response> {
// 1. Read the raw body exactly once — signatures cover these bytes.
const headers = request.headers;
const body = await request.text();
// 2. Verify before anything else.
if (!(await verifyWebhook({ headers, body, secret: process.env.POLAR_WEBHOOK_SECRET! }))) {
return new Response('invalid signature', { status: 401 });
}
// 3. Parse. Cheap, and it produces the dedupe key.
const event = await parseWebhookEvent({ headers, body });
// 4. Drop repeats.
if (await alreadyProcessed(event.idempotencyKey)) {
return new Response(null, { status: 204 });
}
// 5. Persist.
await handle(event);
await markProcessed(event.idempotencyKey);
// 6. Acknowledge.
return new Response(null, { status: 204 });
}
1. Verify
verifyWebhook returns false — never throws — for a missing header, a stale timestamp, a malformed
secret, or a mismatched signature. Reject with 401 and stop; do not log the body of an unverified
request as if it were real.
2. Dedupe
Providers retry deliveries, sometimes for days. Every parsed event carries idempotencyKey — a
required, provider-namespaced <provider>:<id> string that is stable across retries and dashboard
replays — so there is nothing to derive; store it with a TTL and drop repeats. Where that key comes
from on each provider is covered in Webhooks.
const DEDUPE_TTL_SECONDS = 60 * 60 * 24 * 3;
async function alreadyProcessed(key: string): Promise<boolean> {
return (await kv.get(`webhook:${key}`)) !== null;
}
async function markProcessed(key: string): Promise<void> {
await kv.put(`webhook:${key}`, '1', { expirationTtl: DEDUPE_TTL_SECONDS });
}
Mark the delivery processed after the work succeeds, not before: if persistence throws, you want the
provider’s retry to find no dedupe entry and try again. Know the limit too — idempotencyKey dedupes
redeliveries of one event, and providers often emit several events for a single state change, which
is where the read-then-upsert race
starts.
3. Persist — upsert by subscription ID
Events arrive out of order and overlap. The only robust model is upsert keyed on the provider subscription ID, with a staleness guard:
import type { Subscription, WebhookEvent } from 'revenue-sdk';
async function handle(event: WebhookEvent): Promise<void> {
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 linkCustomerFromCheckout(event.checkout);
break;
default:
logger.debug('unmapped webhook', { providerType: event.providerType });
}
}
async function upsertSubscription(subscription: Subscription): Promise<void> {
const stored = await db.subscriptions.find({ providerSubscriptionId: subscription.id });
// Guard against a retried older event overwriting newer state.
if (
stored?.currentPeriodEnd &&
subscription.currentPeriodEnd &&
subscription.currentPeriodEnd < stored.currentPeriodEnd
) {
return;
}
await db.subscriptions.upsert({
where: { providerSubscriptionId: subscription.id },
data: {
providerCustomerId: subscription.customerId,
status: subscription.status,
cancelAtPeriodEnd: subscription.cancelAtPeriodEnd,
productId: subscription.productId,
currentPeriodEnd: subscription.currentPeriodEnd,
endsAt: subscription.endsAt,
endedAt: subscription.endedAt,
},
});
}
The read and the write above are two statements, and that is a race — see Why does provisioning fire twice? below before you ship it.
Three things this gets right:
subscription.createdis not special. Dodo Payments never emits it, and providers re-send updates freely. Upsert everywhere and insertion order stops mattering.- A scheduled cancellation is an update. It arrives as
subscription.updatedwithcancelAtPeriodEnd: true; the terminalsubscription.canceledcomes later. Store both fields. order.paidcovers renewals, and fires once per payment. It is the uniform “money received” signal — on Lemon Squeezy it maps from bothorder_createdandsubscription_payment_success, because renewals emit no order, while theinitialinvoice that duplicates the first payment is skipped. The attachedOrderis the same shapeclient.orders.listreturns, so a delivery you dropped can be reconciled later by ID — see Orders.
Where a side effect really is about a transition rather than a state, prefer
event.subscriptionChange — 'past_due', 'paused', 'uncanceled' and friends — over diffing rows
yourself. It is an edge the provider named, it is deduped by idempotencyKey like any other event, and
it never re-fires on a later subscription.updated the way a payload field such as cancelAtPeriodEnd
would. Coverage is uneven across providers, so check
the matrix before relying on it.
4. Return 2xx fast
Providers time deliveries out (a few seconds) and count slow responses as failures. Acknowledge as soon as the event is durably recorded, and move anything slow — emails, provisioning, analytics — off the request path.
On Cloudflare Workers:
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
// ... verify, dedupe, parse ...
// Durable, fast: write the state the app reads.
await upsertSubscription(event.subscription);
// Slow and non-critical: after the response.
ctx.waitUntil(sendWelcomeEmail(event.subscription));
return new Response(null, { status: 204 });
},
};
Why does provisioning fire twice? The read-then-upsert race
Because upsertSubscription reads the stored row and then writes it in a second statement, and event
dedupe cannot save you from that. Two deliveries handled concurrently can both read the old row before
either writes, so both pass the staleness guard, both see the same “change”, and any side effect hanging
off that diff — a plan-changed email, a provisioning call, a credit grant — fires twice.
Their idempotencyKeys differ by construction, because they are two different events: Polar and Dodo
Payments emit both subscription.active and subscription.updated for a single activation. So
alreadyProcessed never sees a duplicate. This is the trap — event-id dedupe looks like it solves
exactly this, and it does not.
Make the read and the write one statement, and drive side effects off the row the write returns rather than a snapshot you read earlier:
UPDATE subscriptions
SET status = $2, cancel_at_period_end = $3, current_period_end = $4
WHERE provider_subscription_id = $1
AND status IS DISTINCT FROM $2
RETURNING id, status;
Exactly one of the two concurrent deliveries gets a row back; that one sends the email. The other gets
an empty result, which means “somebody else already applied this state” — do nothing and return 204.
Any store with a conditional write does this: a WHERE-guarded UPDATE … RETURNING, a Mongo
findOneAndUpdate with the old value in the filter, a DynamoDB conditional update, a Durable Object.
This is especially likely on edge runtimes, where concurrent deliveries land in separate isolates and share no in-process lock, so a single-instance mutex or a “check the cache first” guard buys you nothing.
Choose your status codes deliberately
| Situation | Status | Why |
|---|---|---|
| Handled, or a duplicate | 204 |
Success. Stops retries. |
| Signature invalid | 401 |
Never retryable — the secret is wrong or the payload was forged. |
| Unknown sender on a shared endpoint | 400 |
Don’t ask for a retry you can’t handle. |
| Your database is down | 500 |
Do ask for a retry. |
| Event type you don’t handle | 204 |
It’s a success — you simply have nothing to do. |
Returning 500 for an unrecognized event type is a common mistake: the provider retries it forever.
parseWebhookEvent maps anything unmapped to unknown instead of throwing, so the default branch is a
no-op by design.
Logging that stays safe
logger.info('webhook', {
type: event.type,
providerType: event.providerType,
idempotencyKey: event.idempotencyKey,
});
// The payload lives on the narrowed event, so log it inside the branch that has it.
if (event.type === 'order.paid') {
logger.info('payment', { orderId: event.order.id, amount: event.order.amount });
}
Log the normalized fields, not the raw envelope. Provider payloads can contain customer addresses and
partial payment details, and error cause values hold the provider’s response body verbatim — see
Errors.
Serving several providers from one endpoint
import { detectWebhookProvider, type ProviderName, type ProviderWebhooks } from 'revenue-sdk';
import * as dodoPayments from 'revenue-sdk/dodo-payments';
import * as lemonSqueezy from 'revenue-sdk/lemon-squeezy';
import * as paddle from 'revenue-sdk/paddle';
import * as polar from 'revenue-sdk/polar';
import * as stripe from 'revenue-sdk/stripe';
const HANDLERS: Record<string, ProviderWebhooks> = {
'dodo-payments': dodoPayments,
'lemon-squeezy': lemonSqueezy,
paddle,
polar,
stripe,
};
const SECRETS: Partial<Record<ProviderName, string>> = {
polar: process.env.POLAR_WEBHOOK_SECRET,
stripe: process.env.STRIPE_WEBHOOK_SECRET,
};
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 ? HANDLERS[provider] : undefined;
const secret = provider ? SECRETS[provider] : undefined;
if (!helpers || !secret) {
return new Response('unknown sender', { status: 400 });
}
if (!(await helpers.verifyWebhook({ headers, body, secret }))) {
return new Response('invalid signature', { status: 401 });
}
await handle(await helpers.parseWebhookEvent({ headers, body }));
return new Response(null, { status: 204 });
}
Note that importing all five providers this way defeats tree shaking. If you only ever run one provider per deployment, pick it with an environment variable at build time instead.
For the signature schemes behind verifyWebhook — and why code copied between Polar and Dodo Payments
fails — see
how to verify webhook signatures from Stripe, Polar, Lemon Squeezy, Paddle, and Dodo Payments.