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

Client & providers

How createClient, the provider factories, and the thin-adapter/fat-client split fit together, plus tree shaking and per-request clients on Workers.

revenue-sdk is built on a clean split: thin provider adapters translate one billing provider’s REST API into a normalized contract, and a single fat client layers all the cross-provider behavior on top — validation, capability gating, a bounded rate-limit retry, and the listAll iterators.

createClient

createClient takes a provider and returns a RevenueClient with grouped namespaces:

import { createClient } from 'revenue-sdk';
import { stripe } from 'revenue-sdk/stripe';

const client = createClient({
  provider: stripe({ secretKey: process.env.STRIPE_SECRET_KEY! }),
});

client.products; // list / listAll / get
client.checkouts; // create / get
client.customers; // get / list / listAll / create / update
client.subscriptions; // get / list / listAll / cancel / uncancel / changePlan / endTrial / pause / resume / revoke
client.customerPortal; // createSession
client.usage; // report
client.licenseKeys; // list / listAll / get / update
client.orders; // list / listAll / get / getInvoiceUrl

The client also exposes two read-only fields derived from the provider:

PropType
providerName?'polar' | 'lemon-squeezy' | 'stripe' | 'paddle' | 'dodo-payments' | 'testing'

Which provider backs this client.

Type'polar' | 'lemon-squeezy' | 'stripe' | 'paddle' | 'dodo-payments' | 'testing'
capabilities?RevenueCapabilities

The provider capability object — see Capabilities.

TypeRevenueCapabilities

Provider factories

Each provider ships a factory on its own subpath. A factory returns a RevenueProvider — the thin adapter the client drives. There is no registry and no dynamic import: you name the provider you want, and your bundler drops the other four.

import { polar } from 'revenue-sdk/polar';

const provider = polar({
  accessToken: process.env.POLAR_ACCESS_TOKEN!,
  server: 'sandbox', // 'production' (default) | 'sandbox'
});
import { lemonSqueezy } from 'revenue-sdk/lemon-squeezy';

const provider = lemonSqueezy({
  apiKey: process.env.LEMON_SQUEEZY_API_KEY!,
  storeId: process.env.LEMON_SQUEEZY_STORE_ID!,
});
import { stripe } from 'revenue-sdk/stripe';

const provider = stripe({ secretKey: process.env.STRIPE_SECRET_KEY! });
import { paddle } from 'revenue-sdk/paddle';

const provider = paddle({
  apiKey: process.env.PADDLE_API_KEY!,
  server: 'sandbox', // 'production' (default) | 'sandbox'
});
import { dodoPayments } from 'revenue-sdk/dodo-payments';

const provider = dodoPayments({
  apiKey: process.env.DODO_PAYMENTS_API_KEY!,
  server: 'test', // 'live' (default) | 'test'
});

Every factory also accepts an optional baseUrl (used verbatim, overriding server) and an injectable fetch. See the provider pages for the complete option tables.

baseUrl may carry a path prefix — https://gateway.internal/polar keeps /polar in front of every request path — so a proxy or gateway mount point works without rewriting.

A factory validates its required options up front and throws a RevenueError with code validation naming the missing ones. An unset environment variable therefore fails where you construct the provider rather than as a confusing 401 on the first call. Only the option names appear in the message; a value you did pass is never echoed.

Standalone subpath exports

Not everything needs a client. Each provider subpath also exports plain functions that take their own inputs and are used on their own:

  • verifyWebhook and parseWebhookEvent — a webhook handler holds a signing secret, not an API key, and often runs in a different process than the client. See Webhooks.
  • validateLicenseKey, activateLicenseKey, deactivateLicenseKey on revenue-sdk/polar, revenue-sdk/lemon-squeezy, and revenue-sdk/dodo-payments — these take no credential at all and are meant to run inside the app you ship. See License keys.
import { validateLicenseKey, verifyWebhook } from 'revenue-sdk/polar';

Merchant-side license-key management does need the credential and stays on the client, as client.licenseKeys.

Thin adapter, fat client

The RevenueProvider contract is deliberately minimal — a flat list of methods like listProducts, createCheckout, and cancelSubscription. Everything that should behave identically across providers lives in the client, not the adapter:

Parameter validation

Empty ids, empty item lists, and non-positive quantities are rejected before a request is made.

Capability gating

Unsupported options throw unsupported instead of being silently dropped.

Bounded retry

One retry on rate_limited when the provider’s Retry-After is small — never for usage.report.

Pagination

The listAll async generators follow cursors for you.

The payoff: a new provider only has to implement the thin adapter, and it instantly inherits all of the client’s behavior. That applies to your providers too — RevenueProvider is exported from the root and createClient accepts any object that satisfies it, so an in-house billing system or a provider the SDK doesn’t ship plugs into the same client. See Bring your own provider.

Tree shaking

Providers are exposed as factory functions on separate subpaths, never as a registry keyed by name. That is what makes the package tree-shakable — the package is marked sideEffects: false, so importing revenue-sdk/polar leaves the Stripe form encoder, the Lemon Squeezy JSON layer, and everything else out of your bundle.

Injectable fetch

Every factory accepts an optional fetch implementation. In production you omit it and the platform’s global fetch is used; in tests you inject a stub so no real network call is made:

const provider = stripe({
  secretKey: 'sk_test_123',
  fetch: async () =>
    new Response(JSON.stringify({ id: 'prod_1', name: 'Pro' }), {
      headers: { 'content-type': 'application/json' },
    }),
});

const client = createClient({ provider });

The SDK always calls the injected fetch detached from any holder object, which is what workerd requires — passing someObject.fetch directly is safe.

Per-request instantiation on Workers

Provider factories are cheap object literals and hold no module-scope mutable state, so creating a client per request is the correct pattern on Cloudflare Workers and similar isolate-based runtimes — secrets live on the per-request env binding, not in module scope:

import { createClient } from 'revenue-sdk';
import { stripe } from 'revenue-sdk/stripe';

interface Env {
  STRIPE_SECRET_KEY: string;
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const client = createClient({
      provider: stripe({ secretKey: env.STRIPE_SECRET_KEY }),
    });

    const { items } = await client.products.list({ limit: 10 });
    return Response.json(items.map((product) => product.name));
  },
};

See Cloudflare Workers for the full picture.

Cancellation

Every method accepts an optional signal — a standard AbortSignal threaded through to fetch:

const { items } = await client.products.list({
  signal: AbortSignal.timeout(5_000),
});

An abort rejects with the original AbortError rather than being wrapped, so error.name === 'AbortError' still distinguishes a timeout from a provider failure.

Retry configuration

The client performs one bounded retry on rate_limited when the provider sent a Retry-After that is at most maxRetryAfterSeconds (default 10). Set it to 0 to disable the retry entirely:

const client = createClient({
  provider: polar({ accessToken: process.env.POLAR_ACCESS_TOKEN! }),
  retry: { maxRetryAfterSeconds: 10 },
});

usage.report is the one exception: it never retries, because a replayed usage event is deduplicated only when you passed an idempotencyKey — see Usage-based billing.

Anything beyond that single retry is yours to build — see Errors for retryable and retryAfter.

Weighing this against one SDK per provider? Stripe SDK alternatives for multi-provider billing compares the two approaches, including where a unified client gets in the way.

Next: Products & prices

What a “product” means per provider, and which identifier a checkout accepts.

Last updated on August 8, 2026

Was this page helpful?