npm packages are not installable yet: [email protected] can be published after 2026-09-16 10:30 UTC. Details
Tollstile

Charge per call on Cloudflare Workers

Price a Cloudflare Worker with @tollstile/fetch, keep the ledger in D1 with @tollstile/sqlite, and reconcile from a cron trigger.

Public Beta · early access

Goal: a Worker where GET /weather costs $0.01 and GET /reports/:id costs $0.05, with every charge recorded in a D1 database and reconciled every five minutes.

Prerequisites

  • A Cloudflare account and wrangler.
  • A D1 database: npx wrangler d1 create tollstile-ledger.
  • For live rails: the prerequisites of the rail you choose, for example x402.
npm install tollstile @tollstile/fetch @tollstile/sqlite

Runnable example in the repository: examples/cloudflare-workers.

1. Configure the Worker

wrangler.jsonc
{
  "name": "paid-api",
  "main": "src/index.ts",
  "compatibility_date": "2026-09-01",
  "d1_databases": [
    { "binding": "DB", "database_name": "tollstile-ledger", "database_id": "<from wrangler d1 create>" }
  ],
  "triggers": { "crons": ["*/5 * * * *"] }
}

Tollstile signs quotes with secret. Every isolate must share it, or a quote issued by one fails in another:

openssl rand -base64 32 | npx wrangler secret put TOLLSTILE_SECRET
# for wrangler dev, put TOLLSTILE_SECRET=<32+ characters> in .dev.vars

2. Apply the ledger schema

sqliteSchema is plain DDL. Commit it as a D1 migration:

npx wrangler d1 migrations create DB tollstile
node --input-type=module -e "import('@tollstile/sqlite').then((m) => console.log(m.sqliteSchema))" > migrations/0001_tollstile.sql
npx wrangler d1 migrations apply DB --local    # and without --local for the remote database

3. Write the Worker

src/index.ts
import { paid } from "@tollstile/fetch";
import { sqliteLedger } from "@tollstile/sqlite";
import { createTollstile, testRail } from "tollstile";

type Env = { DB: D1Database; TOLLSTILE_SECRET: string };

function createApp(env: Env) {
  // D1 has no interactive transactions; the ledger only needs atomic batches.
  const ledger = sqliteLedger({
    execute: async (sql, params) => (await env.DB.prepare(sql).bind(...params).all()).results,
    transaction: async (statements) =>
      (await env.DB.batch(statements.map(({ sql, params }) => env.DB.prepare(sql).bind(...params)))).map(
        (result) => result.results,
      ),
  });

  const toll = createTollstile({ rails: [testRail()], ledger, secret: env.TOLLSTILE_SECRET });

  const weather = paid(toll.price("$0.01"), () => Response.json({ forecast: "clear" }));
  const report = paid(toll.price("$0.05", { resource: "GET /reports/:id" }), (request) =>
    Response.json({ id: new URL(request.url).pathname.split("/")[2] }),
  );

  return {
    toll,
    fetch(request: Request): Promise<Response> {
      const { pathname } = new URL(request.url);
      if (request.method === "GET" && pathname === "/weather") return weather(request);
      if (request.method === "GET" && /^\/reports\/[^/]+$/.test(pathname)) return report(request);
      return Promise.resolve(new Response("Not found", { status: 404 }));
    },
  };
}

// One instance per isolate, created on first use.
let app: ReturnType<typeof createApp> | undefined;

export default {
  fetch(request, env) {
    app ??= createApp(env);
    return app.fetch(request);
  },
  scheduled(_controller, env, ctx) {
    app ??= createApp(env);
    ctx.waitUntil(app.toll.reconcile());
  },
} satisfies ExportedHandler<Env>;
  • paid(gate, handler) turns a priced route into a (request) => Promise<Response> handler. It does not route; match paths yourself, or use Hono, which also runs on Workers.
  • The resource is "<METHOD> <pathname>". Name routes with parameters (resource: "GET /reports/:id") so every id is not its own resource.

4. Call it

npx wrangler dev
curl -i localhost:8787/weather                         # 402 Payment Required
curl -i -H "Payment: test" localhost:8787/weather      # 200 OK, payment-receipt: test_settlement_chg_…
curl -i -H "Payment: test" localhost:8787/reports/42   # 200 OK

Check the ledger:

npx wrangler d1 execute DB --local \
  --command "SELECT id, resource, payment, fulfillment, amount_micros FROM tollstile_charges"

Both charges must be settled / completed, with resource GET /weather and GET /reports/:id.

5. Switch to a live rail

Replace testRail() with a live rail and keep secrets in wrangler secret put:

src/index.ts
import { x402 } from "@tollstile/x402";

const toll = createTollstile({
  rails: [
    x402({
      network: "eip155:84532",
      payTo: env.PAY_TO,
      denomination: "USD",
      rpcUrl: env.RPC_URL,
    }),
  ],
  ledger,
  secret: env.TOLLSTILE_SECRET,
});

Add PAY_TO and RPC_URL to Env. The packages use Web-standard fetch and crypto.subtle and import no Node.js modules.

The test rail's fake provider lives in memory per isolate. That is fine for trying the flow, but reconciliation of test charges only sees what the same isolate settled.

Verification status

@tollstile/fetch is tested with Node 22's Request and Response, not on the Workers runtime. @tollstile/sqlite runs its conformance suite on node:sqlite and through an adapter that behaves like D1; the D1 adapter above has not been executed against D1. To verify, run wrangler dev with a local database and the steps above.

When things fail

What happensResult
Handler returns 400+ or throwsReleased or refunded; a thrown error is rethrown to the runtime
Settlement rejectedThe body is cancelled; a fresh 402 with error code settlement_rejected is returned
Settlement outcome unknownThe response is returned without a receipt; the cron trigger's reconcile() resolves it
A D1 batch failsThe whole batch rolls back; the request errors and nothing is half-written
A charge is left mid-lifecycle because an isolate was evictedThe next scheduled reconcile() releases, settles, or looks it up, once it is older than olderThanMs (15 minutes by default)

Next

Retries

Send Idempotency-Key on the first paid request and keep it on retries of the same request (on MCP: _meta["tollstile/idempotency-key"]). A completed charge returns 409 already_paid; an in-flight charge returns 409 request_in_progress; an unknown outcome returns 503 payment_outcome_unknown. Do not start a new payment while the outcome is unknown. Released/refunded attempts may run again. See Idempotency for request matching and rail-specific key scopes.

On this page