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 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 set retry: { maxRetryAfterSeconds: 0 }.
  • 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.

Last updated on August 6, 2026

Was this page helpful?