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.

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?