Skip to content

Server middleware

Terminal window
npm install @tallypay/server express
# or
npm install @tallypay/server hono

Both Express and Hono middleware accept the same configuration object:

PropertyTypeRequiredDescription
facilitatorUrlstringYesBase URL of your x402 facilitator (e.g. https://x402-facilitator.cdp.coinbase.com). The middleware calls POST {facilitatorUrl}/verify and POST {facilitatorUrl}/settle.
payTostringYesMerchant receive address. Included in the 402 response body.
pricestringYesAmount in base units (e.g. "10000" for 0.01 USDC with 6 decimals).
networkstringYesCAIP-2 network identifier (e.g. "eip155:8453" for Base).
assetstringYesAsset name (e.g. "USDC").
apiKeystringNoTallyPay API key (tp_live_…). Enables lifecycle event emission to the trace collector. Without it, payment gating still works but traces are off.
collectorUrlstringNoOverride the collector URL (defaults to https://collector.tallypay.dev). Set to http://127.0.0.1:8787 for local dev.
descriptionstringNoHuman-readable description of what the payment unlocks. Included in the 402 body.

import express from "express";
import { tallypay } from "@tallypay/server/express";
const app = express();
app.use(
"/api/premium",
tallypay({
facilitatorUrl: process.env.FACILITATOR_URL!,
payTo: process.env.MERCHANT_ADDRESS!,
price: "10000",
network: "eip155:8453",
asset: "USDC",
apiKey: process.env.TALLYPAY_API_KEY, // optional
description: "Premium data access",
}),
);
app.get("/api/premium", (_req, res) => {
res.json({ message: "Paid content" });
});
app.listen(3000);

The middleware runs before your route handler. Unpaid requests never reach your handler — they receive a 402 with payment instructions.


import { Hono } from "hono";
import { tallypay } from "@tallypay/server/hono";
const app = new Hono();
app.use(
"/api/premium/*",
tallypay({
facilitatorUrl: "https://x402-facilitator.cdp.coinbase.com",
payTo: "0x...",
price: "10000",
network: "eip155:8453",
asset: "USDC",
}),
);
app.get("/api/premium/data", (c) => c.json({ message: "Paid content" }));
export default app;

  1. Request arrives — generates a traceId.
  2. No payment-signature header — returns 402 with:
    • JSON body: { x402Version: 1, accepts: [{ scheme, network, asset, payTo, maxAmountRequired, description }] }
    • payment-required header: base64-encoded version of the same body
    • x-trace-id header: the generated trace ID
    • Emits 402_ISSUED (if apiKey is set).
  3. payment-signature header present — forwards to facilitator:
    • POST {facilitatorUrl}/verify with the raw signature as the body.
    • Expects { valid: boolean, reason?: string }.
    • If invalid, returns 402 + error. Emits PAYMENT_ERROR.
    • POST {facilitatorUrl}/settle with the same body.
    • Expects { settled: boolean, txHash?: string }.
    • If not settled, returns 402 + error. Emits PAYMENT_ERROR.
  4. Settlement succeeds — sets x-trace-id and payment-response headers, calls next() (your route handler runs). Emits PAYMENT_COMPLETE.
  5. Any exception — returns 500. Emits PAYMENT_ERROR.
EventWhenMetadata
402_ISSUEDNo payment headerendpoint, method, price, network
VERIFY_REQUESTEDBefore calling /verifyendpoint
VERIFY_RESULTAfter /verify returnsvalid, reason
SETTLE_REQUESTEDBefore calling /settleendpoint
SETTLE_RESULTAfter /settle returnssettled, txHash
PAYMENT_COMPLETESettlement succeededendpoint, txHash
PAYMENT_ERRORAny failurestep, reason or error

All events are fire-and-forget. If the collector is unreachable, events are silently dropped. The middleware never adds latency to payment processing.

The middleware expects your facilitator to expose:

  • POST /verify — body: raw payment signature string. Response: { valid: boolean, reason?: string }.
  • POST /settle — body: raw payment signature string. Response: { settled: boolean, txHash?: string }.

Timeouts: 10s for verify, 30s for settle.

If your facilitator uses different paths or response shapes, write a thin HTTP proxy or adapter.

Omit apiKey and the middleware is a pure open-source x402 gateway — no network calls to TallyPay, no telemetry. Payment gating works identically.