Accept MPP payments
Accept Machine Payments Protocol payments with Stripe (cards through Shared Payment Tokens) and Tempo stablecoins, over HTTP and MCP.
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(chain42431) 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-server1. Build it on the test rail
MPP's two methods move money at different times, and the test rail can play both:
| Rail | Flow | When money moves | Handler fails |
|---|---|---|---|
mppStripe() | upfront | Before the handler: confirming a PaymentIntent captures immediately | Refunded |
mppTempo() | authorization | After the handler: the signed transaction is broadcast only on success | Nothing was broadcast |
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 → refunded2. Switch to MPP
npm install @tollstile/mppimport { 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
flowon a route that uses both rails. A route'sflowapplies 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.01with onlymppStripe()answers a402with 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/reportHTTP/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:
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.tsExpect 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/sdkimport { 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:
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 happens | Stripe (upfront) | Tempo (authorization) |
|---|---|---|
| No or malformed credential, wrong realm, expired or tampered challenge | 402 with fresh challenges | 402 with fresh challenges |
| Credential retried after successful fulfillment | 409 already_paid via the challenge ID; no second PaymentIntent | 409 already_paid via the challenge ID; no second transfer |
| Stripe declines, before the handler | 402 with error code payment_rejected; the handler does not run | — |
| Stripe does not answer, before the handler | 503; the handler does not run; the charge is unknown until reconciliation looks it up | — |
Handler throws or answers 400+ | Refunded through Stripe | Released; nothing is broadcast |
| Broadcast refused after the handler | — | Output withheld; fresh 402 with error code settlement_rejected |
| Broadcast answer lost | — | Output 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
MPP rails reference
Options, capabilities, lookup, stored data.
Charge for MCP tool calls
Run reconciliation
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.
Charge per call on Cloudflare Workers
Price a Cloudflare Worker with @tollstile/fetch, keep the ledger in D1 with @tollstile/sqlite, and reconcile from a cron trigger.
Admit only verified agents
Require Web Bot Auth HTTP message signatures from trusted agents with verifiedAgent() from @tollstile/web-bot-auth, alongside payment.