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.
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/sqliteRunnable example in the repository: examples/cloudflare-workers.
1. Configure the Worker
{
"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.vars2. 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 database3. Write the Worker
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 OKCheck 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:
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 happens | Result |
|---|---|
Handler returns 400+ or throws | Released or refunded; a thrown error is rethrown to the runtime |
| Settlement rejected | The body is cancelled; a fresh 402 with error code settlement_rejected is returned |
| Settlement outcome unknown | The response is returned without a receipt; the cron trigger's reconcile() resolves it |
| A D1 batch fails | The whole batch rolls back; the request errors and nothing is half-written |
| A charge is left mid-lifecycle because an isolate was evicted | The 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.
Add a paid route to Next.js
Charge per call for a Next.js App Router route handler with @tollstile/next, from the test rail to x402 with a Postgres ledger.
Accept MPP payments
Accept Machine Payments Protocol payments with Stripe (cards through Shared Payment Tokens) and Tempo stablecoins, over HTTP and MCP.