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

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 route 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;
}

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();

  // The secret comes off `env`, never a module-scope constant.
  if (!(await verifyWebhook({ headers, body, secret: env.POLAR_WEBHOOK_SECRET }))) {
    return new Response('invalid signature', { status: 401 });
  }

  const event = await parseWebhookEvent({ headers, body });

  // Dedupe on env.PROCESSED, then persist. Both live in the webhook handler guide.
  await persist(env, event);

  // Non-critical follow-up work, after the response.
  ctx.waitUntil(notifySlack(event));

  return new Response(null, { status: 204 });
}

persist is the dedupe-and-upsert body from the webhook handler guideenv.PROCESSED is the KV namespace its alreadyProcessed/markProcessed pair writes to. 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, and that is all it does. It is not a queue: an eviction or a crash drops the work, it still runs under the Worker’s CPU and duration limits, and a burst of deliveries starts a burst of background work with no back-pressure.

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

  • Retries sleep. The client’s single bounded retry waits up to maxRetryAfterSeconds (default 10 seconds) before retrying — on a rate limit for any call, and on a transport failure or 5xx for reads (including every page of a listAll). On a latency-sensitive route, lower it or set retry: { maxRetryAfterSeconds: 0 }. The wait honors the request signal, so an aborted or timed-out request never sits out the delay.
  • Catalog reads are chatty. Stripe fetches prices per product and Lemon Squeezy fetches a price model per variant, so products.listAll can 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.
  • Concurrent deliveries land in separate isolates, sharing no lock — so an in-process mutex buys you nothing against the read-then-upsert race.

Last updated on August 8, 2026

Was this page helpful?