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.