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
client.subscriptions; // get / list / listAll / cancel / uncancel / changePlan / endTrial / revoke
client.customerPortal; // createSession
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.
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.
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.
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 },
});
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.