Packages

Billing

Stripe, Paddle, LemonSqueezy, and Polar behind a BillingProvider port, with HMAC raw-body webhook verification.

@caisson/billing wires four providers (Stripe, Paddle, LemonSqueezy, and Polar) behind the same BillingProvider port, so the rest of the base depends on the port, not the vendor. Stripe pairs with Stripe Tax, with the operator as the merchant of record. Paddle, LemonSqueezy, and Polar are each Merchant of Record themselves, they compute, collect, and remit tax on the sale. Paddle is the provider Caisson's own store runs on; Stripe, LemonSqueezy, and Polar stay buyer-selectable drivers for products composed on the base, dormant until you supply that provider's credentials, each satisfying the identical port, swap providers by injecting a different driver, not by rewriting call sites.

The contract

Webhooks are verified against the raw request body with a timing-safe HMAC compare. A body that has been parsed and re-serialized fails verification, the signature is over the exact bytes the provider sent, not the JSON your framework reconstructed.

import { verifyStripeWebhook } from "@caisson/billing";

// Raw body — never the parsed JSON. Returns void on success; throws AuthnError on a bad signature.
verifyStripeWebhook(rawBody, req.headers["stripe-signature"], secret);

Paddle's Paddle-Signature header carries a ts= timestamp and one or more h1= HMAC values; the signed payload is ${ts}:${rawBody} (colon-joined, unlike Stripe's period-joined scheme). The default timestamp tolerance is 5 seconds: Paddle's own documented SDK default, and a delivery rejected for skew is retried with a fresh signature, so the tight window costs nothing durable.

import { verifyPaddleWebhook } from "@caisson/billing";

// Raw body, timing-safe HMAC compare, 5s timestamp tolerance by default.
verifyPaddleWebhook(rawBody, req.headers["paddle-signature"], secret);

Paddle fires one event, transaction.completed, for both a one-time purchase and every subscription charge, the origin field tells them apart: web/api is the subscription's first charge (subscription_create, grants), subscription_recurring is a renewal (subscription_cycle, grants), and subscription_charge is a mid-cycle one-time charge that must not re-grant the cycle allotment (non-granting). Idempotency anchors on the Paddle transaction id (txn_…), stable across redeliveries, rather than invoice_id, which Paddle documents as deprecated.

LemonSqueezy and Polar are two more MoR drivers behind the same port, for products where the buyer wants one of those rails instead. Both are dormant until you supply that provider's credentials, nothing is imported or constructed unless you configure it.

import { verifyLemonSqueezyWebhook } from "@caisson/billing";

// LemonSqueezy's signature is a bare HMAC-SHA256 hex digest over the raw body — no timestamp,
// so there is no replay-tolerance window to configure.
verifyLemonSqueezyWebhook(rawBody, req.headers["x-signature"], secret);
import { verifyPolarWebhook } from "@caisson/billing";

// Standard Webhooks splits the signature across three headers — join them before verifying.
// Default tolerance is 300s.
const signatureHeader = [
  req.headers["webhook-id"],
  req.headers["webhook-timestamp"],
  req.headers["webhook-signature"],
].join(".");
verifyPolarWebhook(rawBody, signatureHeader, secret);

Because billing rides a port, a test driver stands in for any of the four providers under the standards gate, suites assert on charge and webhook behavior without a network call.

API reference

The public surface of @caisson/billing: the four raw-body verifiers, the BillingProvider port, each provider's config contract, and the DomainBillingEvent webhook event map. The driver factories that construct a live BillingProvider (createStripeBilling and its siblings) and the provider → DomainBillingEvent parsers live in the commercial @caisson/billing-orchestration; this package ships the port, the config types, and the open verify + event-schema seam every driver is built on.

Verification functions

function verifyStripeWebhook(
  rawBody: string,
  signatureHeader: string,
  secret: string,
  options?: VerifyOptions,
): void;
function verifyPaddleWebhook(
  rawBody: string,
  signatureHeader: string,
  secret: string,
  options?: VerifyOptions,
): void;
function verifyLemonSqueezyWebhook(
  rawBody: string,
  signatureHeader: string,
  secret: string,
): void;
function verifyPolarWebhook(
  rawBody: string,
  signatureHeader: string,
  secret: string,
  options?: VerifyOptions,
): void;

interface VerifyOptions {
  /** Max signature age in seconds. Stripe/Polar default 300; Paddle defaults to 5 (Paddle's own documented SDK default). */
  toleranceSec?: number;
  /** Override "now" (unix seconds) — for tests. */
  now?: number;
}

All four return void: a valid signature simply returns, and every failure, malformed header, wrong secret, tampered body, timestamp outside tolerance, throws AuthnError (from @caisson/kernel, HTTP 401), never a boolean or a silently-false result. verifyLemonSqueezyWebhook takes no VerifyOptions: its signature carries no timestamp, so there's no replay window to configure. verifyPolarWebhook expects one already-joined signatureHeader: Standard Webhooks splits the signature across three request headers (webhook-id, webhook-timestamp, webhook-signature); join them `${id}.${timestamp}.${signature}` before calling, as shown above.

The provider port

interface CheckoutInput {
  accountId: string;
  priceId: string;
  mode: "payment" | "subscription";
  successUrl: string;
  cancelUrl: string;
}

interface BillingProvider {
  verifyAndParse(
    rawBody: string,
    signatureHeader: string,
    opts?: VerifyOptions,
  ): DomainBillingEvent | null;
  createCheckout(input: CheckoutInput): Promise<{ url: string }>;
}

BillingProvider is the interface every driver implements; call sites hold this type, never a vendor SDK client, so swapping providers means injecting a different driver behind these same two methods. Each provider's config is its own typed contract:

interface StripeConfig {
  webhookSecret: string;
  apiKey: string;
}

interface PaddleConfig {
  webhookSecret: string;
  apiKey: string;
  env?: "sandbox" | "production"; // defaults to "production"
  onWarn?: (message: string) => void; // non-fatal parse anomalies, e.g. a malformed refund line
}

interface LemonSqueezyConfig {
  apiKey: string;
  webhookSecret: string;
  storeId: string; // required on every checkout's relationships.store
}

interface PolarConfig {
  accessToken: string;
  webhookSecret: string;
  env?: "sandbox" | "production"; // defaults to "production"
}

The webhook event map

verifyAndParse maps every provider's payload onto one provider-agnostic union, validated at runtime by the exported DomainBillingEventSchema: a Zod discriminated union on type, every member strict, so an unknown field fails closed rather than passing through silently:

type DomainBillingEvent =
  | {
      type: "purchase.completed";
      sourceEventId: string;
      accountId: string;
      amountTotal: number;
      currency: string;
      paymentId: string;
      lineItems: {
        priceId: string;
        quantity: number;
        itemId: string;
        chargedAmount: number;
      }[];
    }
  | { type: "subscription.created"; sourceEventId: string; accountId: string }
  | { type: "subscription.updated"; sourceEventId: string; accountId: string }
  | {
      type: "subscription.canceled";
      sourceEventId: string;
      accountId: string;
      subscriptionId: string;
    }
  | {
      type: "refund.completed";
      sourceEventId: string;
      accountId: string;
      paymentId: string;
      amountRefunded: number;
      currency: string;
      fullyRefunded: boolean;
      adjustmentId: string;
      items: {
        itemId: string;
        amountRefunded: number;
        fullyRefunded: boolean;
      }[];
    }
  | {
      type: "chargeback.detected";
      sourceEventId: string;
      accountId: string;
      paymentId: string;
      amountDisputed: number;
      currency: string;
    }
  | {
      type: "invoice.paid";
      sourceEventId: string;
      accountId: string;
      amountTotal: number;
      currency: string;
      subscriptionId: string;
      priceId: string;
      billingReason: string;
      invoiceId: string;
    };

sourceEventId is the provider's own event id on every member, it flows straight into the credit wallet's idempotency key, so a redelivered webhook grants exactly once. lineItems on purchase.completed is every paid line of the transaction, not just the first: a multi-item cart is one provider transaction carrying several lines, and each one is fulfilled. refund.completed splits the same way, fullyRefunded: true means revoke everything keyed by paymentId; fullyRefunded: false means act per line on items[], keyed by `${adjustmentId}:${itemId}`. chargeback.detected is alert-only: nothing in @caisson/billing grants, revokes, or claws back credits from it, a chargeback is the provider absorbing the dispute, and any account action is a manual operator review. invoice.paid carries billingReason so a non-cycle invoice grants nothing, regardless of caller logic upstream.