Cloudflare Workers + Axo
Add Axo payments to any Cloudflare Workers app with the Agents SDK.
Cloudflare Workers run at the edge with near-zero cold starts, making them a great fit for agent payment workflows. The template wires up buy-side L402 fetching, sell-side paywalls, and Durable Object token persistence in one repo — using the Cloudflare Agents SDK for stateful agent logic.
Quick Start
Clone and install
git clone https://github.com/zbdpay/cloudflare-template
cd cloudflare-template
npm installInitialize your wallet
npx @axobot/cli init --key $ZBD_API_KEYThis creates a Lightning address for your agent and prints it. You'll use both values in the next step.
Create .dev.vars
Create a .dev.vars file in the project root for local development:
ZBD_API_KEY=your_zbd_api_key
ZBD_LIGHTNING_ADDRESS=your_agent_lightning_addressWarning
Cloudflare Workers don't use process.env. Access secrets via this.env inside the Agent class or c.env inside a Hono route handler. Never copy process.env.ZBD_API_KEY patterns from Node.js examples — they won't work at runtime.
Start the dev server
npm run devVite starts a local dev server with Wrangler under the hood. The Durable Object and Hono routes are both available at http://localhost:5173.
Buy Side — Paying L402 APIs
The Agent class handles buy-side payments directly via agentFetch and zbdPayL402Invoice. Token caching is backed by Durable Object storage so the agent never re-pays for a resource it already has a valid token for.
import { agentFetch, zbdPayL402Invoice } from "@axobot/fetch";
import { Agent, callable } from "agents";
import { DurableObjectTokenCache } from "./do-token-cache";
export class ZBDPaymentAgent extends Agent<Env, AgentState> {
private async agentFetchBuySide(url: string, maxSats: number) {
return agentFetch(url, {
tokenCache: new DurableObjectTokenCache(this.ctx.storage),
maxPaymentSats: maxSats,
pay: async (challenge, context) =>
zbdPayL402Invoice(challenge, context ?? { url: "" }, {
apiKey: this.env.ZBD_API_KEY,
}),
waitForPayment: async (paymentId) => {
const maxAttempts = 10;
for (let i = 0; i < maxAttempts; i++) {
await new Promise((r) => setTimeout(r, 2000));
const res = await fetch(
`https://api.zbdpay.com/v0/payments/${paymentId}`,
{ headers: { apikey: this.env.ZBD_API_KEY } },
);
const body = await res.json();
const status = body?.data?.status ?? body?.status;
if (status === "completed" || status === "paid" || status === "settled") {
return {
status: "completed" as const,
paymentId,
preimage: body?.data?.preimage ?? body?.preimage,
amountPaidSats: body?.data?.amount_sats ?? body?.amount_sats,
};
}
if (status === "failed" || status === "error" || status === "cancelled") {
return { status: "failed" as const, paymentId, failureReason: `payment_${status}` };
}
}
return { status: "pending" as const, paymentId };
},
});
}
}A few things worth noting:
zbdPayL402Invoicetakes three arguments:(challenge, context, options). Theoptionsobject is whereapiKeylives. This is not a factory function — do not call it with a single config object instead of the three positional arguments.this.env.ZBD_API_KEYis the correct access pattern inside the Agent class.process.envis not available in Workers.DurableObjectTokenCachewrapsthis.ctx.storagedirectly — no HTTP stub needed. The agent's own Durable Object storage is the cache.
Sell Side — Monetizing Your API
createHonoPaymentMiddleware from @axobot/pay wraps a Hono route and returns a 402 challenge to any caller that hasn't paid yet.
Warning
Import from @axobot/pay (the main entry point), not from a subpath like @axobot/pay/hono. The package exports createHonoPaymentMiddleware directly.
import { Hono } from "hono";
import { routeAgentRequest } from "agents";
import { createHonoPaymentMiddleware } from "@axobot/pay";
import { ZBDPaymentAgent } from "../src/agent";
const app = new Hono<{ Bindings: Env }>();
// Lazy-init: createHonoPaymentMiddleware reads c.env.ZBD_API_KEY at call time.
// Env bindings only exist on the request context — not at module load time.
app.use("/api/premium", async (c, next) => {
const middleware = createHonoPaymentMiddleware({
amount: 100,
currency: "SAT",
apiKey: c.env.ZBD_API_KEY,
tokenStore: sellSideTokenStore,
});
return middleware(c, next);
});
app.get("/api/premium", (c) =>
c.json({
data: "Premium content unlocked. Thanks for the sats!",
timestamp: new Date().toISOString(),
}),
);
export default {
async fetch(req: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
return (await routeAgentRequest(req, env)) ?? app.fetch(req, env, ctx);
},
};
export { ZBDPaymentAgent };Info
The middleware is created inside the route handler (not at module scope) because c.env only exists on the request context. Workers don't have a startup phase — env bindings arrive with the first request.
Token Storage
The template uses two separate storage classes backed by Durable Object storage:
DurableObjectTokenCache — buy-side L402 token cache. Implements TokenCache from @axobot/fetch. Stores tokens under the key token:${url} and checks expiry on every read.
import type { DurableObjectStorage } from "@cloudflare/workers-types";
import type { TokenCache, TokenRecord } from "@axobot/fetch";
export class DurableObjectTokenCache implements TokenCache {
constructor(private readonly storage: DurableObjectStorage) {}
async get(url: string): Promise<TokenRecord | null> {
const token = await this.storage.get<TokenRecord>(`token:${url}`);
if (!token) return null;
// Evict expired tokens on read
if (token.expiresAt && token.expiresAt <= Math.floor(Date.now() / 1000)) {
await this.storage.delete(`token:${url}`);
return null;
}
return token;
}
async set(url: string, token: TokenRecord): Promise<void> {
await this.storage.put(`token:${url}`, token);
}
async delete(url: string): Promise<void> {
await this.storage.delete(`token:${url}`);
}
}DurableObjectTokenStore — sell-side settled payment store. Implements TokenStore from @axobot/pay. Stores records under the key settled:${chargeId}:${paymentHash} so the middleware can verify a payment was made before serving protected content.
import type { DurableObjectStorage } from "@cloudflare/workers-types";
import type { TokenStore } from "@axobot/pay";
export class DurableObjectTokenStore implements TokenStore {
constructor(private readonly storage: DurableObjectStorage) {}
async isSettled(chargeId: string, paymentHash: string): Promise<boolean> {
const record = await this.storage.get(`settled:${chargeId}:${paymentHash}`);
return record !== undefined;
}
async markSettled(record: {
chargeId: string;
paymentHash: string;
amountSats: number;
expiresAt: number;
resource: string;
}): Promise<void> {
await this.storage.put(
`settled:${record.chargeId}:${record.paymentHash}`,
{ ...record, verifiedAt: Math.floor(Date.now() / 1000) },
);
}
}Info
Both classes take DurableObjectStorage directly — not a stub. Pass this.ctx.storage from inside the Agent class. The key prefixes (token: and settled:) keep the two namespaces from colliding.
Agent Class
ZBDPaymentAgent extends Agent from the Cloudflare Agents SDK. Methods decorated with @callable() can be invoked remotely from a client. this.setState() persists state across requests, and this.schedule() queues a method to run after a delay.
import { Agent, callable } from "agents";
export type AgentState = {
lastFetchUrl: string;
lastFetchAt: number;
totalSpentSats: number;
};
export class ZBDPaymentAgent extends Agent<Env, AgentState> {
public initialState: AgentState = {
lastFetchUrl: "",
lastFetchAt: 0,
totalSpentSats: 0,
};
@callable()
public async fetchPaid(url: string, maxSats = 1000) {
const { response, spentSats } = await this.agentFetchBuySide(url, maxSats);
const body = await response.text();
this.setState({
...this.state,
lastFetchUrl: url,
lastFetchAt: Math.floor(Date.now() / 1000),
totalSpentSats: this.state.totalSpentSats + spentSats,
});
return { status: response.status, body, spentSats };
}
@callable()
public async startScheduledFetch(url: string): Promise<void> {
this.setState({ ...this.state, lastFetchUrl: url, lastFetchAt: Math.floor(Date.now() / 1000) });
// Schedule runs after 60 seconds (argument is seconds, not milliseconds)
await this.schedule(60, "scheduledFetch", { url });
}
public async scheduledFetch({ url }: { url: string }): Promise<void> {
await this.fetchPaid(url);
await this.schedule(60, "scheduledFetch", { url });
}
public async onStart(): Promise<void> {
if (this.state.lastFetchUrl) {
await this.schedule(60, "scheduledFetch", { url: this.state.lastFetchUrl });
}
}
}Warning
@callable() requires TC39 stage-3 decorators. The template's tsconfig.json uses "experimentalDecorators": false (or omits it) and relies on the native decorator support in TypeScript 5+. Do not add "experimentalDecorators": true — it enables the older, incompatible decorator proposal.
Deploying
npm run deployThis runs wrangler deploy under the hood. Add secrets to production before or after deploying:
wrangler secret put ZBD_API_KEY
wrangler secret put ZBD_LIGHTNING_ADDRESSCheck your Worker's logs in real time:
wrangler tail