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

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:

PropType
request?Request

A Web-standard Request. Provide this or headers + body.

TypeRequest
headers?Headers | Record<string, string>

Request headers, when you pass the body separately. Names are matched case-insensitively.

TypeHeaders | Record<string, string>
body?string

The raw request body — required with headers.

Typestring
secret?string

The endpoint signing secret. verifyWebhook only.

Typestring

verifyWebhook 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';
PropType
type?WebhookEventType

The normalized event type.

TypeWebhookEventType
providerType?string

The provider's original event type string, always preserved.

Typestring
subscription?Subscription

Set on subscription.* events.

TypeSubscription
order?Order

Set on order.paid.

TypeOrder
checkout?Checkout

Set on checkout.completed (and on not-yet-complete checkout events, which report unknown).

TypeCheckout
raw?unknown

The untouched provider envelope.

Typeunknown

What each type means

  • subscription.created — a new subscription exists. Dodo Payments has no such event; its first signal arrives as subscription.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.canceledterminal only. The subscription has actually ended.
  • order.paid — money was received, including renewals. On Lemon Squeezy this covers both order_created (first payment) and subscription_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:

  1. Dedupe on the delivery ID. Store it with a TTL and drop repeats:

    Provider Delivery ID
    Polar webhook-id header
    Dodo Payments webhook-id header
    Stripe id on the event envelope (raw.id)
    Paddle event_id on the envelope (raw.event_id)
    Lemon Squeezy no delivery ID — dedupe on (providerType, subscription/order id, updated timestamp)
  2. Upsert, never insert. Key subscription state on event.subscription.id. Reordered events then converge instead of conflicting.

  3. Ignore stale writes. Compare against a monotonic field (the provider’s updated_at in raw, or currentPeriodEnd) and skip anything older than what you have stored.

  4. Return 2xx fast. Acknowledge within a couple of seconds and move slow work off the request — see the webhook handler guide.

Last updated on August 6, 2026

Was this page helpful?