skip to content
← back to work
Unify PaymentUnify Payment

Unify Payment

Unified TypeScript SDK for 10 payment providers.

TypeScript Payments SDK Discriminated Unions Cloudflare Workers
github.com/shakibhasan09/unify-payment

The thing I didn’t want to build

The lazy version of “unify ten payment providers” is to design a single normalized API and hide every difference behind it. I tried that first. It produces a least-common-denominator surface that nobody can actually ship a product on, because Stripe has line items and Polar has subscription seats and Razorpay has Indian-specific tax rails and Bkash has nothing resembling Western metadata. Whatever you carve out as the common shape is by definition the smallest set.

The version that ended up working flips the problem. Instead of hiding the provider, the SDK knows which provider you picked and types your call site to that provider — so a Stripe instance accepts Stripe params, a Polar instance accepts Polar params, and TypeScript catches the mismatch the moment you change one to the other. The unification happens in the factory and the webhook event shape, not the call site.

How that’s expressed in types

type PaymentConfig =
  | { provider: "stripe";       apiKey: string; ... }
  | { provider: "paypal";       clientId: string; clientSecret: string; ... }
  | { provider: "polar";        accessToken: string; ... }
  | { provider: "paddle";       apiKey: string; ... }
  | { provider: "lemonsqueezy"; apiKey: string; ... }
  // ... 5 more

type CheckoutParamsForConfig<T extends PaymentConfig> =
    T extends StripeConfig        ? StripeCheckoutSessionParams       :
    T extends PaypalConfig        ? PaypalCheckoutSessionParams       :
    T extends PolarConfig         ? PolarCheckoutSessionParams        :
    // ...

interface PaymentInstance<T extends PaymentConfig> {
    createCheckoutSession(params: CheckoutParamsForConfig<T>): Promise<CheckoutSession>;
    verifyWebhook?(params: VerifyWebhookParams): Promise<WebhookEvent>;
}

function createPayment<T extends PaymentConfig>(config: T): PaymentInstance<T>;

At a call site:

const payment = createPayment({ provider: "stripe", apiKey: process.env.STRIPE_SECRET_KEY! });

const { url } = await payment.createCheckoutSession({
    amount: 2999,
    currency: "usd",
    successUrl: "...",
    cancelUrl: "...",
    productName: "Pro Plan",
    metadata: { userId: "..." },
    overrides: { /* full Stripe SessionCreateParams */ },
});

Switch provider: "stripe" to provider: "polar" and TypeScript immediately complains about productName and metadata not existing on PolarCheckoutSessionParams. That’s the whole point: you’re not moving to a different SDK, you’re typing the difference.

Provider implementations

Each provider is a small adapter, not a subclass. Two flavors. Stripe and Paddle get wrapped around their native SDKs because those SDKs are the source of truth for both types and wire format — the wrapper just normalizes the unified shape. Everything else is an HTTP adapter extending a shared UnifyFetch base class that handles JSON encoding, status checking, and error mapping. Each provider implements its own getApiBaseUrl() and methods. Polar is a typical example:

class Polar extends UnifyFetch {
    async getCheckoutUrl(payload: IPolarCheckoutCreatePayload): Promise<string> {
        const [res] = await this.jsonFetch<IPolarCheckoutResponse>(
            `${this.getApiBaseUrl()}/checkouts/`,
            { method: "POST", body: JSON.stringify(payload) },
        );
        return res.url;
    }

    async verifySignature(payload: { body, signature, secret, webhookId, timestamp }) {
        const key = await crypto.subtle.importKey("raw", encoder.encode(secret),
            { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
        const expected = await crypto.subtle.sign("HMAC", key,
            encoder.encode(`${webhookId}.${timestamp}.${body}`));
        // constant-time compare with the provided signature
        return { type: parsed.type, event: parsed.data };
    }
}

crypto.subtle rather than node:crypto matters because the SDK is deployed in Workers as often as in Node — the WebCrypto path works in Node 18+, in Workers, in Deno, and in browsers without a platform branch.

Amounts, one convention

Stripe takes cents. PayPal takes decimals. Bkash takes BDT integers. SSLCommerz takes decimals with two places. Coinbase wants a string. Picking one rule for the SDK prevents an entire class of bugs: every public method takes the amount in the smallest unit (cents, paise, poisha), and providers that natively expect decimals divide by 100 inside the adapter. Callers never have to remember which provider wants what — they always send 2999, never 29.99. The first time you ship a $0.30 charge instead of a $30 charge to production, you really start to appreciate this kind of rule.

Webhook verification is optional on purpose

Not every provider signs webhooks. Forcing a verifyWebhook method onto Bkash or Nagad (which don’t) would push that burden onto every consumer, even ones who never plan to switch to those providers. So the interface declares verifyWebhook? as optional, and TypeScript tells consumers at compile time whether their provider has a verifier or whether they need to handle the call as undefined. Stripe, LemonSqueezy, Polar, Razorpay, Paddle, and Coinbase implement it. The three Bangladesh providers don’t, and the type system reflects that truth instead of pretending otherwise.

The webhook event itself is normalized:

interface WebhookEvent {
    type: string;    // "checkout.session.completed", "order.paid", ...
    data: unknown;   // provider payload (provider-specific shape)
    raw: unknown;    // raw event for debugging
}

Build and ship

pnpm + Turbo monorepo. packages/node/ is the published package, apps/test/ is a Cloudflare Workers Vitest harness. Bundling is tsup with CJS + ESM + .d.ts output. Stripe and Paddle are the only non-trivial runtime dependencies; the eight HTTP providers add nothing beyond their own type definitions. Releases go through Changesets — every PR adds a markdown changeset file, changesets publish bumps the version and ships to npm. Tests run inside the actual Workers runtime through @cloudflare/vitest-pool-workers, so we’re verifying the library where it’ll actually be deployed.

The tradeoffs, honestly

ChoiceWhy
Discriminated unions, not inheritanceEach provider is independent. Adding one means a new config type, params type, class, and switch case — no base-class refactor that ripples through everything.
Conditional type for paramsThe factory returns a typed instance whose createCheckoutSession signature is provider-specific. The IDE does the work the docs would otherwise have to.
overrides escape hatch on StripeThe unified params cover 90% of use cases. The other 10% gets overrides?: Partial<StripeSDK.Checkout.SessionCreateParams> so you never have to drop down to the native SDK.
crypto.subtle over node:cryptoWorks in Workers, Deno, browsers, and Node 18+ without a branch. The cost is async signature ops, which were already async anyway.
Optional webhook methodProvider parity is a coverage goal, not an API design goal. Pretending Bkash has signed webhooks is worse than admitting it doesn’t.