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

Next.js

Charge per call for Next.js App Router route handlers with paid() from @tollstile/next.

Public Beta · early access
npm install tollstile @tollstile/next
lib/toll.ts
import { createTollstile, memoryLedger, testRail } from "tollstile";

export const toll = createTollstile({ rails: [testRail()], ledger: memoryLedger() });
app/reports/[id]/route.ts
import { paid } from "@tollstile/next";
import { toll } from "@/lib/toll";

export const GET = paid(
  toll.price("$0.01", { resource: "GET /reports/[id]" }),
  async (request, { params, payment }) => {
    const { id } = await params;
    return Response.json({ id, paidWith: payment.via });
  },
);
curl -i localhost:3000/reports/42                      # 402 Payment Required
curl -i -H "Payment: test" localhost:3000/reports/42   # 200 OK, payment-receipt: test_settlement_…

paid(gate, handler) returns a route handler. Unpaid requests get a 402 with every rail's challenge; paid requests run your handler with { params, payment }, and the payment is completed — settled, or released — before the response is returned. The package has no dependency on next.

Route handlers only: pages, server components, and server actions are not guarded. Tutorial: Add a paid route to Next.js.

Behavior

Handler resultPayment
A response with status below 400Completed as succeeded: settled on the authorization flow, receipt headers added
A response with status 400 or aboveCompleted as failed: released, or refunded on the upfront flow
Throws or rejectsCompleted as failed, then the error is rethrown
Settlement rejectedThe response body is cancelled and a fresh 402 with error code settlement_rejected is returned instead
Settlement 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.
  • Call payment.fulfill() inside the handler to mark the service as delivered earlier; a later failure then does not undo the charge.
  • The resource is "<METHOD> <pathname>", without the query string. Dynamic segments make that set unbounded, so name the route with toll.price(amount, { resource }).
  • Responses with immutable headers (from fetch() or Response.redirect()) are copied so the receipt can be added; the copy keeps the status, headers, and unread body stream.
  • The handler receives the request typed as Request. Next.js passes a NextRequest; use new URL(request.url) for the URL.
  • memoryLedger() lives in one process. On serverless deployments use a database ledger, and give every instance the same secret.

Options

paid(gate, handler, options?)

OptionTypeDescription
principal(request: Request) => Principal | null | Promise<Principal | null>Resolves the authenticated caller for subscriber() and credits(). Defaults to no principal.

Verification status

Tested by calling the exported handler the way Next.js does — (request, { params: Promise }) — against the test rail, memory ledger, and memory balance: the 402 → pay with the quote → 200 round trip, params passthrough, receipts on immutable responses, releases on thrown errors and 4xx, a single completion per request, principals reaching credits(), and type assignability for static, dynamic, and catch-all routes.

Not run inside a Next.js application. To verify, add the example to an app, run next build (which type-checks route exports) and next dev, then the two curl commands.

Idempotent retries

The adapter forwards Idempotency-Key to core automatically. Send the same key on the first paid request and its retries; keep the request unchanged. A completed payment returns 409 already_paid with chargeId, settlement, and result; the handler does not run again. In-flight requests return 409 request_in_progress, unknown outcomes return 503 payment_outcome_unknown, and a key used for a different request returns 422 idempotency_key_reused.

Use payment.fulfill({ resultRef }) to record where your application stored its result. Tollstile does not cache the response. Released or refunded attempts may run again; subscriber grants are not deduplicated. See Idempotency for matching, rail identity scopes, and retry limits.

On this page