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

Express

Charge per call for Express 5 routes with paid() from @tollstile/express.

Public Beta · early access
npm install tollstile @tollstile/express express
server.ts
import express from "express";
import { createTollstile, memoryLedger, testRail } from "tollstile";
import { paid } from "@tollstile/express";

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

app.get(
  "/weather",
  paid(toll.price("$0.01"), (req, res, { payment }) => {
    res.json({ forecast: "clear", paidWith: payment.via });
  }),
);

app.listen(3000);
curl -i localhost:3000/weather                      # 402 Payment Required
curl -i -H "Payment: test" localhost:3000/weather   # 200 OK, payment-receipt: test_settlement_…

paid(gate, handler, options?) wraps one route handler. Unpaid requests get a 402 with every rail's challenge. Paid requests run your handler, and the response is held at the moment it would send its headers until the payment is completed, so the receipt is on the response and settlement has finished before anything reaches the client.

Tutorial: Accept x402 payments in Express.

Behavior

The handler is called as handler(req, res, { payment, next }).

What happensPayment
The response sends its headers with status below 400Completed as succeeded: settled on the authorization flow, receipt headers added
The response sends its headers with status 400 or aboveCompleted as failed: released, or refunded on the upfront flow
The handler throws or rejectsCompleted as failed, then Express receives the error
The handler calls next(error)Completed as failed when the error response is sent
The handler calls next()Decided by whichever handler sends the response
Settlement is rejectedThe held response is discarded and a fresh 402 with error code settlement_rejected is sent instead
Settlement is unknownThe response is sent without a receipt; reconciliation resolves the charge
  • When the outcome is decided. The first call that would send headers — res.send, res.json, res.end, res.writeHead, res.write, res.flushHeaders, or a piped stream — decides the outcome from the status code. That call and everything after it are held until the payment completes. A streaming handler gets its first bytes out only after settlement, and an error halfway through a stream does not undo the charge.
  • When completion fails (for example, the ledger is unreachable), the held response is discarded and the error goes to your Express error handlers. If control had already left the handler through next() or a thrown error, the connection is closed instead.
  • Writes made while the response is held return false; 'drain' is emitted once they are let through, so piped streams resume.
  • Call payment.fulfill() inside the handler to mark the service as delivered earlier; a later failure then does not undo the charge.
  • Resource. "<METHOD> <route path>", e.g. GET /api/users/:id, when the handler is on a string route path, and "<METHOD> <pathname>" otherwise (for example under app.use). Mount paths come from req.baseUrl, so a mount path with parameters is recorded with its values; set toll.price(amount, { resource }) there.
  • Request. Rails read proofs from a Web Request built from req: the method, the absolute URL from req.protocol, req.host, and req.originalUrl (both honor Express's trust proxy setting), and every header. The body is rebuilt from what Express parsed.

Dynamic prices and the body

Run express.json(), express.text(), or express.raw() before paid(). A dynamic price then reads the body from context.request, and the quote binds to it: parsed objects are serialized with sorted keys, so a retry with the same JSON matches. If a request has a body no parser read, pricing it fails with CONFIG_INVALID rather than pricing an empty body. Keyed charges also hash the reconstructed request body, even on fixed-price routes. Install the appropriate parser for those routes as well.

Options

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

Verification status

Tested against a real Express 5.2 app on Node's HTTP server with fetch: the 402 → pay with the quote → 200 round trip; res.json, res.send, res.writeHead, a piped stream larger than the socket buffer, and writers waiting for 'drain'; completion finishing before the client sees the response; releases on thrown errors, rejected promises, next(error), and 4xx; next() to a later handler; a failing completion replacing the response; route-path resource names; and principals reaching credits().

Not tested with middleware that patches the response, such as compression, or behind a reverse proxy. To verify such a setup, run the two curl commands: the second must return 200 with a payment-receipt header and the full body, and your ledger must show the charge settled before the response was received.

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