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:
providerName?'polar' | 'lemon-squeezy' | 'stripe' | 'paddle' | 'dodo-payments' | 'testing'
Which provider backs this client.
'polar' | 'lemon-squeezy' | 'stripe' | 'paddle' | 'dodo-payments' | 'testing'capabilities?RevenueCapabilities
The provider capability object — see Capabilities.
RevenueCapabilitiesProvider 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:
verifyWebhookandparseWebhookEvent— 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,deactivateLicenseKeyonrevenue-sdk/polar,revenue-sdk/lemon-squeezy, andrevenue-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.