Cloudflare Workers
Run revenue-sdk on Cloudflare Workers — secret bindings, per-request clients, injectable fetch, webhook handling, and the ctx.waitUntil caveats.
revenue-sdk was built for Workers: zero dependencies, no node:* imports, and nothing but fetch,
Web Crypto, TextEncoder, URL, and btoa/atob. It runs without the nodejs_compat flag.
Configuration
Nothing special is required in wrangler.toml beyond a recent compatibility date:
name = "billing"
main = "src/index.ts"
compatibility_date = "2026-01-01"
[[kv_namespaces]]
binding = "PROCESSED"
id = "…"
Secrets live on env, not in module scope
Workers isolates are reused across requests and have no process.env at module load. Put every API key
and signing secret in a secret binding and read it from the per-request env:
wrangler secret put POLAR_ACCESS_TOKEN
wrangler secret put POLAR_WEBHOOK_SECRET
export interface Env {
POLAR_ACCESS_TOKEN: string;
POLAR_WEBHOOK_SECRET: string;
PROCESSED: KVNamespace;
}
Create the client per request
Provider factories are cheap object literals with no module-scope mutable state, so per-request construction is the correct pattern, not a compromise:
import { createClient } from 'revenue-sdk';
import { polar } from 'revenue-sdk/polar';
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const client = createClient({
provider: polar({ accessToken: env.POLAR_ACCESS_TOKEN }),
});
const { items } = await client.products.list({ limit: 20 });
return Response.json(items.map(({ id, name, prices }) => ({ id, name, prices })));
},
};
The cost is a couple of object allocations. There is nothing to cache, and caching a client would bind a secret to an isolate that outlives the request.
Injecting fetch
If you need Workers-specific fetch options — a cf object, a Hyperdrive binding, a service binding — pass
your own fetch:
const provider = polar({
accessToken: env.POLAR_ACCESS_TOKEN,
fetch: (input, init) => fetch(input, { ...init, cf: { cacheTtl: 0 } }),
});
The SDK always calls the injected function detached from any holder object. That matters on workerd:
calling a bound method through a property access (someBinding.fetch(...) stored and re-invoked) throws
Illegal invocation. Wrapping it in an arrow function, as above, is always safe.
A complete Worker
Routing, a checkout endpoint, a portal redirect, and a webhook handler in one module:
import { createClient } from 'revenue-sdk';
import { parseWebhookEvent, polar, verifyWebhook } from 'revenue-sdk/polar';
export interface Env {
POLAR_ACCESS_TOKEN: string;
POLAR_WEBHOOK_SECRET: string;
PROCESSED: KVNamespace;
}
const DEDUPE_TTL_SECONDS = 60 * 60 * 24 * 3;
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);
const client = createClient({ provider: polar({ accessToken: env.POLAR_ACCESS_TOKEN }) });
if (url.pathname === '/api/checkout' && request.method === 'POST') {
const form = await request.formData();
const checkout = await client.checkouts.create({
items: [{ product: String(form.get('checkoutRef')) }],
customerEmail: String(form.get('email')),
successUrl: `${url.origin}/thanks`,
});
return Response.redirect(checkout.url, 303);
}
if (url.pathname === '/api/portal') {
const session = await client.customerPortal.createSession({
customerId: url.searchParams.get('customer') ?? '',
returnUrl: `${url.origin}/account`,
});
return Response.redirect(session.url, 302);
}
if (url.pathname === '/api/webhooks' && request.method === 'POST') {
return handleWebhook(request, env, ctx);
}
return new Response('not found', { status: 404 });
},
};
async function handleWebhook(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const headers = request.headers;
const body = await request.text();
if (!(await verifyWebhook({ headers, body, secret: env.POLAR_WEBHOOK_SECRET }))) {
return new Response('invalid signature', { status: 401 });
}
const deliveryId = headers.get('webhook-id');
if (deliveryId && (await env.PROCESSED.get(deliveryId)) !== null) {
return new Response(null, { status: 204 });
}
const event = await parseWebhookEvent({ headers, body });
switch (event.type) {
case 'subscription.created':
case 'subscription.updated':
case 'subscription.canceled':
await upsertSubscription(env, event.subscription!);
break;
case 'order.paid':
await recordPayment(env, event.order!);
break;
}
if (deliveryId) {
await env.PROCESSED.put(deliveryId, '1', { expirationTtl: DEDUPE_TTL_SECONDS });
}
// Non-critical follow-up work, after the response.
ctx.waitUntil(notifySlack(event));
return new Response(null, { status: 204 });
}
Web Crypto is used for signature verification, so verifyWebhook works on Workers unchanged — no
polyfill, no nodejs_compat.
ctx.waitUntil caveats
ctx.waitUntil extends the isolate’s lifetime past the response. It is useful, and it is not a queue:
- No retries. If the promise rejects, the work is simply lost — and the provider has already been told the delivery succeeded.
- Not durable. An eviction or a crash drops it.
- Bounded. It still runs under the Worker’s CPU and duration limits.
- No back-pressure. A burst of deliveries starts a burst of background work.
So the rule is: anything the customer’s access depends on happens before the response. Only
best-effort work goes in waitUntil.
// Correct.
await upsertSubscription(env, event.subscription!); // critical — awaited
ctx.waitUntil(sendWelcomeEmail(event.subscription!)); // best effort
// Wrong — a dropped promise silently un-subscribes a paying customer.
ctx.waitUntil(upsertSubscription(env, event.subscription!));
For work that must not be lost, push onto a Cloudflare Queue inside the request and process it in a consumer, which gives you real retries.
Timeouts and cancellation
Thread an AbortSignal so a slow provider can’t burn the whole CPU budget:
const { items } = await client.products.list({
signal: AbortSignal.timeout(5_000),
});
An abort rejects with the original AbortError, so it is distinguishable from a RevenueError.
What still needs care
- Rate-limit retries sleep. The client’s single bounded retry waits up to
maxRetryAfterSeconds(default 10 seconds) before retrying. On a latency-sensitive route, lower it or setretry: { maxRetryAfterSeconds: 0 }. - Catalog reads are chatty. Stripe fetches prices per product and Lemon Squeezy fetches a price
model per variant, so
products.listAllcan be many subrequests. Workers cap subrequests per request — cache the catalog in KV rather than listing it on every page view. - Cursors are opaque strings, so they round-trip safely through KV, a query string, or a Durable Object.