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

Accept MPP payments

Accept Machine Payments Protocol payments with Stripe (cards through Shared Payment Tokens) and Tempo stablecoins, over HTTP and MCP.

Public Beta · early access

Goal: GET /report costs $1.00 and accepts two MPP methods on the same route: Stripe charge and Tempo charge. The same price also guards an MCP tool.

Prerequisites

  • Node.js 22.18 or later, which runs TypeScript files directly. On older versions, run the files with npx tsx.
  • Stripe: a Stripe account that can use Shared Payment Tokens, its test secret key, and your Business Network Profile id (profile_…).
  • Tempo: a receiving address, a TIP-20 token address (for example pathUSD), and a JSON-RPC URL — https://rpc.moderato.tempo.xyz (chain 42431) for the Moderato testnet.
  • Two secrets of 32+ random characters: one for Tollstile quotes, one for MPP challenge ids.
npm install tollstile @tollstile/hono hono @hono/node-server

1. Build it on the test rail

MPP's two methods move money at different times, and the test rail can play both:

RailFlowWhen money movesHandler fails
mppStripe()upfrontBefore the handler: confirming a PaymentIntent captures immediatelyRefunded
mppTempo()authorizationAfter the handler: the signed transaction is broadcast only on successNothing was broadcast
server.ts
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { tollstile } from "@tollstile/hono";
import { createTollstile, memoryLedger, testRail } from "tollstile";

const toll = createTollstile({
  rails: [testRail()],
  ledger: memoryLedger(),
  onEvent: (event) => {
    if (event.type === "charge.moved") console.log(event.charge.flow, `${event.charge.payment}/${event.charge.fulfillment}`);
  },
});

const app = new Hono();

// Like Tempo charge: settle after the handler succeeded.
app.get("/report", tollstile(toll.price("$1.00")), (c) => c.json({ report: "…" }));

// Like Stripe charge: settle first, refund if the handler fails.
app.get("/report-upfront", tollstile(toll.price("$1.00", { flow: "upfront" })), (c) => c.json({ report: "…" }));
app.get("/broken-upfront", tollstile(toll.price("$1.00", { flow: "upfront" })), (c) => c.json({ error: "failed" }, 500));

serve({ fetch: app.fetch, port: 3000 });
node server.ts
curl -i -H "Payment: test" localhost:3000/report           # authorization: settling → settled
curl -i -H "Payment: test" localhost:3000/report-upfront   # upfront: settled before the handler runs
curl -i -H "Payment: test" localhost:3000/broken-upfront   # upfront: settled, then refund_pending → refunded

2. Switch to MPP

npm install @tollstile/mpp
server.ts
import { mppStripe, mppTempo } from "@tollstile/mpp";

const toll = createTollstile({
  rails: [
    mppStripe({
      realm: "api.example.com",
      secret: process.env.MPP_SECRET!, // binds challenge ids; a list rotates
      secretKey: process.env.STRIPE_SECRET_KEY!,
      networkId: process.env.STRIPE_NETWORK_ID!, // profile_…
    }),
    mppTempo({
      realm: "api.example.com",
      secret: process.env.MPP_SECRET!,
      rpcUrl: "https://rpc.moderato.tempo.xyz",
      chainId: 42431,
      recipient: process.env.TEMPO_RECIPIENT!,
      token: { address: "0x20c0000000000000000000000000000000000000", code: "pathUSD" },
      denomination: "USD",
    }),
  ],
  ledger: memoryLedger(), // use a database ledger in production
  secret: process.env.TOLLSTILE_SECRET!,
});

setInterval(() => void toll.reconcile(), 60_000);

app.get("/report", tollstile(toll.price("$1.00")), (c) => c.json({ report: "…" }));
  • Do not set flow on a route that uses both rails. A route's flow applies to every rail; without it, each rail uses its own.
  • Stripe offers nothing below its minimum charge (USD $0.50) or for sub-cent amounts. A route priced at $0.01 with only mppStripe() answers a 402 with no offers. Price Stripe routes at $0.50 or more, or put another rail next to it.
  • Both MPP charge rails are excluded from upTo() routes. A route with only these rails is refused; a mixed configuration works if another rail supports variable amounts in the authorization flow.

3. Verify over HTTP

curl -i localhost:3000/report
HTTP/1.1 402 Payment Required
www-authenticate: Payment id="VNT8…", realm="api.example.com", method="stripe", intent="charge", request="eyJhbW91bnQiOiIxMDAi…", expires="…", opaque="eyJ0b2xsc3RpbGVfcXVvdGUi…"
www-authenticate: Payment id="…", realm="api.example.com", method="tempo", intent="charge", request="…", expires="…", opaque="…"

The JSON body's accepts[].details holds each challenge as an object. A credential echoes one challenge and adds the method's payload, base64url-encoded in Authorization: Payment.

Stripe, test mode. Create a Shared Payment Token with POST /v1/test_helpers/shared_payment/granted_tokens (payment_method=pm_card_visa, usage limits covering $1.00, and a preview Stripe-Version), then pay:

pay-stripe.ts
const url = process.argv[2] ?? "http://localhost:3000/report";

const unpaid = await fetch(url);
const { accepts } = (await unpaid.json()) as { accepts: { rail: string; details: object }[] };
const challenge = accepts.find((offer) => offer.rail === "mpp-stripe")?.details;
if (challenge === undefined) throw new Error("No mpp-stripe offer: is the price at least Stripe's minimum?");

const credential = Buffer.from(JSON.stringify({ challenge, payload: { spt: process.env.SPT } })).toString("base64url");
const paid = await fetch(url, { headers: { authorization: `Payment ${credential}` } });
console.log(paid.status, paid.headers.get("payment-receipt"), await paid.text());
SPT=spt_… node pay-stripe.ts

Expect 200 and a payment-receipt header, and in the Stripe dashboard a succeeded PaymentIntent with metadata.challenge_id. If Stripe rejects the token parameter, set sptParameter: "payment_method_data[shared_payment_granted_token]". npx mppx@latest validate http://localhost:3000/report checks the challenge format.

Tempo, Moderato. Pay the challenge with the mppx client in pull mode. Expect 200, and the transaction hash from the receipt on the Tempo explorer, sent with transferWithMemo and the challenge's memo.

4. The same price on an MCP tool

npm install @tollstile/mcp @modelcontextprotocol/sdk
mcp-server.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { paidTool } from "@tollstile/mcp";

const server = new McpServer({ name: "reports", version: "1.0.0" });

paidTool(server, "report", { description: "Today's report" }, toll.price("$1.00"), () => ({
  content: [{ type: "text", text: "…" }],
}));

A client that declares capabilities.experimental.payment receives MPP's JSON-RPC error -32042 with the challenges, and retries with the credential in _meta:

mcp-client.ts
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { McpError } from "@modelcontextprotocol/sdk/types.js";

const client = new Client({ name: "agent", version: "1.0.0" }, { capabilities: { experimental: { payment: {} } } });
// await client.connect(transport);

try {
  await client.callTool({ name: "report" });
} catch (error) {
  if (!(error instanceof McpError) || error.code !== -32042) throw error;
  const { challenges } = error.data as { challenges: { method: string }[] };
  const challenge = challenges.find((candidate) => candidate.method === "stripe");

  const result = await client.callTool({
    name: "report",
    _meta: { "org.paymentauth/credential": { challenge, payload: { spt: process.env.SPT } } },
  });
  console.log(result._meta?.["org.paymentauth/receipt"]); // { status: "success", method: "stripe", reference: "pi_…", … }
}

A client that does not declare the capability gets an isError result with Tollstile's denial body in _meta["tollstile/payment-required"].

Verification status

The Stripe and Tempo charge rails are tested against in-process fakes and published vectors (mppx's challenge-id vectors, RFC 8785), never against Stripe or a Tempo node. The MCP rendering is tested against fakes shaped like the MPP MCP transport, not mppx. mppTempoSession() (the Tempo session intent) is experimental. See MPP.

When things fail

What happensStripe (upfront)Tempo (authorization)
No or malformed credential, wrong realm, expired or tampered challenge402 with fresh challenges402 with fresh challenges
Credential retried after successful fulfillment409 already_paid via the challenge ID; no second PaymentIntent409 already_paid via the challenge ID; no second transfer
Stripe declines, before the handler402 with error code payment_rejected; the handler does not run
Stripe does not answer, before the handler503; the handler does not run; the charge is unknown until reconciliation looks it up
Handler throws or answers 400+Refunded through StripeReleased; nothing is broadcast
Broadcast refused after the handlerOutput withheld; fresh 402 with error code settlement_rejected
Broadcast answer lostOutput served without a receipt; unknown until the receipt is found or validBefore passes

With Tempo, the payer can spend the nonce or balance between verification and broadcast. The handler has then run unpaid, the charge ends failed/completed, and onEvent reports SETTLEMENT_REJECTED.

Push mode (modes: ["pull", "push"]) accepts transfers the payer already broadcast. Those payments moved before the handler, and the Tempo rail cannot refund: a failed handler leaves the charge settled/failed for you to refund yourself.

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