Express
Charge per call for Express 5 routes with paid() from @tollstile/express.
npm install tollstile @tollstile/express expressimport 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 happens | Payment |
|---|---|
The response sends its headers with status below 400 | Completed as succeeded: settled on the authorization flow, receipt headers added |
The response sends its headers with status 400 or above | Completed as failed: released, or refunded on the upfront flow |
| The handler throws or rejects | Completed 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 rejected | The held response is discarded and a fresh 402 with error code settlement_rejected is sent instead |
| Settlement is unknown | The 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 underapp.use). Mount paths come fromreq.baseUrl, so a mount path with parameters is recorded with its values; settoll.price(amount, { resource })there. - Request. Rails read proofs from a Web
Requestbuilt fromreq: the method, the absolute URL fromreq.protocol,req.host, andreq.originalUrl(both honor Express'strust proxysetting), 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
| Option | Type | Description |
|---|---|---|
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.