Axo

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 install

Initialize your wallet

npx @axobot/cli init --key $ZBD_API_KEY

This 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_address

Warning

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 dev

Vite 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:

  • zbdPayL402Invoice takes three arguments: (challenge, context, options). The options object is where apiKey lives. 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_KEY is the correct access pattern inside the Agent class. process.env is not available in Workers.
  • DurableObjectTokenCache wraps this.ctx.storage directly — 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 deploy

This 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_ADDRESS

Check your Worker's logs in real time:

wrangler tail

On this page