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

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.

Public Beta · early access

Goal: GET /api/reports/[id] in a Next.js App Router app costs $0.01 per call, with the payment settled before the response is returned.

Prerequisites

  • A Next.js App Router app (route handlers in app/**/route.ts).
  • For production: a PostgreSQL database reachable from your deployment, and the x402 prerequisites from Monetize an API with x402.
npm install tollstile @tollstile/next

@tollstile/next has no dependency on next; it wraps Web-standard route handlers.

Runnable example in the repository: examples/nextjs.

1. Create one instance

lib/toll.ts
import { createTollstile, memoryLedger, testRail } from "tollstile";

export const toll = createTollstile({ rails: [testRail()], ledger: memoryLedger() });

2. Wrap the route handler

app/api/reports/[id]/route.ts
import { paid } from "@tollstile/next";
import { toll } from "@/lib/toll";

export const GET = paid(
  toll.price("$0.01", { resource: "GET /api/reports/[id]" }),
  async (request, { params, payment }) => {
    const { id } = await params;
    return Response.json({ id, paidWith: payment.via });
  },
);

paid(gate, handler, options?) returns a route handler. Your handler receives the request and { params, payment }.

Name the resource on routes with dynamic segments. Without it, the resource is "<METHOD> <pathname>", so every /api/reports/42 becomes its own resource in your ledger and limits.

3. Call it

npm run dev
curl -i localhost:3000/api/reports/42                      # 402 Payment Required, signed quote in the body
curl -i -H "Payment: test" localhost:3000/api/reports/42   # 200 OK
HTTP/1.1 200 OK
payment-receipt: test_settlement_chg_…

{"id":"42","paidWith":"rail"}

Run next build as well: it type-checks the exported GET.

4. Go to production

memoryLedger() lives in one process. On serverless deployments each invocation may run in a different instance, so use a database ledger. With Postgres on Neon's WebSocket Pool:

npm install @tollstile/x402 @tollstile/postgres @neondatabase/serverless
lib/toll.ts
import { Pool } from "@neondatabase/serverless";
import { postgresLedger } from "@tollstile/postgres";
import { x402 } from "@tollstile/x402";
import { createTollstile } from "tollstile";

const pool = new Pool({ connectionString: process.env.DATABASE_URL });

const ledger = postgresLedger({
  query: (sql, params) => pool.query(sql, params),
  transaction: async (work) => {
    const client = await pool.connect();
    try {
      await client.query("BEGIN");
      const result = await work((sql, params) => client.query(sql, params));
      await client.query("COMMIT");
      return result;
    } catch (error) {
      await client.query("ROLLBACK");
      throw error;
    } finally {
      client.release();
    }
  },
});

export const toll = createTollstile({
  rails: [
    x402({
      network: "eip155:84532",
      payTo: process.env.PAY_TO!,
      denomination: "USD",
      rpcUrl: process.env.RPC_URL!,
    }),
  ],
  ledger,
  secret: process.env.TOLLSTILE_SECRET!, // 32+ random characters, the same in every instance
});

Apply the schema once, as a migration: see Applying the schema.

Every instance must share secret, or a quote issued by one instance fails in another.

Run reconciliation on a schedule from a route your scheduler (for example, Vercel Cron) calls:

app/api/reconcile/route.ts
import { toll } from "@/lib/toll";

export async function GET(request: Request) {
  if (request.headers.get("authorization") !== `Bearer ${process.env.CRON_SECRET}`) {
    return new Response("Unauthorized", { status: 401 });
  }
  return Response.json(await toll.reconcile());
}

Verify

  1. With the test rail, the two curl commands return 402 with a quote in the body, then 200 with a payment-receipt header.
  2. With x402, curl -i returns 402 with a PAYMENT-REQUIRED header. Pay with the reference client script and check that tollstile_charges has one row in settled.

Verification status

The Next.js adapter is tested by calling the exported handler the way Next.js does, including type assignability for static, dynamic, and catch-all routes. It has not been run inside a Next.js application. The x402 rail and the Neon adapter have not been verified against live services.

When things fail

Handler resultPayment
A response below 400Settled; receipt headers added to a copy of the response
A response of 400 or aboveReleased (or refunded on the upfront flow)
Throws or rejectsReleased, then the error is rethrown to Next.js
Settlement rejectedThe body is cancelled and a fresh 402 with error code settlement_rejected is returned
Settlement outcome unknownThe response is returned without a receipt; reconciliation resolves the charge

redirect() and notFound() from next/navigation work by throwing, so they count as failures. Return NextResponse.redirect() when a redirect is the paid result.

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