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

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.created is 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.updated with cancelAtPeriodEnd: true; the terminal subscription.canceled comes later. Store both fields.
  • order.paid covers renewals, and fires once per payment. It is the uniform “money received” signal — on Lemon Squeezy it maps from both order_created and subscription_payment_success, because renewals emit no order, while the initial invoice that duplicates the first payment is skipped. The attached Order is the same shape client.orders.list returns, 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.

Last updated on August 8, 2026

Was this page helpful?