MPP
Machine Payments Protocol rails — Stripe charge (Shared Payment Tokens), Tempo charge, and the experimental Tempo session — over HTTP and MCP.
npm install tollstile @tollstile/mppimport { createTollstile } from "tollstile";
import { mppStripe, mppTempo } from "@tollstile/mpp";
const toll = createTollstile({
rails: [
mppStripe({
realm: "api.example.com",
secret: process.env.MPP_SECRET, // binds challenge ids; 32+ characters, a list rotates
secretKey: process.env.STRIPE_SECRET_KEY,
networkId: "profile_1MqDcVKA5fEO2tZvKQm9g8Yj",
}),
mppTempo({
realm: "api.example.com",
secret: process.env.MPP_SECRET,
rpcUrl: "https://rpc.moderato.tempo.xyz",
chainId: 42431,
recipient: "0x742d35Cc6634C0532925a3b844Bc9e7595f8fE00",
token: { address: "0x20c0000000000000000000000000000000000000", code: "pathUSD" },
denomination: "USD",
}),
],
ledger,
secret: process.env.TOLLSTILE_SECRET,
});Tutorial: Accept MPP payments.
| Rail | Name | MPP method / intent | Flow | Status |
|---|---|---|---|---|
mppStripe() | mpp-stripe | stripe / charge (Shared Payment Tokens) | upfront | Implemented, tested against fakes |
mppTempo() | mpp-tempo | tempo / charge (TIP-20 transfer, pull and push) | authorization | Implemented, tested against fakes |
mppTempoSession() | mpp-tempo-session | tempo / session v2 (payment channels, voucher action) | upfront | Experimental |
Wire format
Shared by every MPP rail.
- Challenge: one
WWW-Authenticate: Payment id, realm, method, intent, request, expires, opaqueper rail.requestandopaqueare base64url of RFC 8785 (JCS) JSON.expiresis the quote's expiry. The402body'saccepts[].detailscarries the same challenge as an object. - Binding:
idis an HMAC-SHA256 over the challenge withsecret, matching mppx's published vectors. The first secret signs; every secret verifies. - Quote: Tollstile's signed quote travels in
opaque, bound by the HMAC, so the quoted price is what is charged. - Credential:
Authorization: Payment <base64url JSON>with the echoedchallengeand the method'spayload; over MCP,_meta["org.paymentauth/credential"]. Verification checks the id, realm, expiry, quote, that the echoedrequestis byte-identical to what this server issues, then the method's proof. Credentials for another method or intent are ignored, so several MPP rails share one route. - Receipt:
Payment-ReceiptplusCache-Control: privateover HTTP;_meta["org.paymentauth/receipt"]over MCP. - MCP challenge: JSON-RPC error
-32042withdata.challengeswhen the client declaredcapabilities.experimental.payment. Verification failures also use-32042, withdata.failure.reason. - Proof id: the challenge id, single-use through the ledger; for sessions, the channel id.
mppStripe(options)
| Option | Default | Purpose |
|---|---|---|
realm, secret | required | Challenge realm and HMAC secret(s) |
secretKey | required | Stripe API key, sent only as Authorization: Bearer |
networkId | required | Stripe Business Network Profile id (methodDetails.networkId) |
paymentMethodTypes | ["card"] | methodDetails.paymentMethodTypes |
apiVersion | "2026-07-29.preview" | Stripe-Version. Shared Payment Tokens require a preview version |
sptParameter | "shared_payment_granted_token" | Or "payment_method_data[shared_payment_granted_token]", if your account needs the form Stripe's SPT guide shows |
searchLagMs | 10 minutes | How long a Stripe Search miss is not trusted after an ambiguous settlement |
apiBase, fetch, clock | Stripe, global, system | Injection points |
| Capability | Value | Why |
|---|---|---|
flows | upfront | Confirming a PaymentIntent captures immediately; there is no hold to release |
authorization | single | One token, one payment |
refund · partialRefund | true · true | Stripe Refunds API |
variableAmount | false | The token is granted for the challenged amount |
quotes | true | Carried in opaque |
lookup | true | By PaymentIntent id, or Stripe Search |
- Offers: none for currencies without a known minor unit, amounts finer than the minor unit (sub-cent USD), or amounts below Stripe's minimum (USD $0.50, GBP £0.30, …).
- Flow: Stripe settles before your handler. A declined payment answers
402(payment_rejected) and an unanswered one503, both before the handler runs. A handler that fails is refunded. - Idempotency key:
tollstile_mpp_<challengeId>, one per challenge. A retry of a released challenge, even with a new token, replays the first PaymentIntent instead of charging twice. - Lookup: by PaymentIntent id when known, otherwise Stripe Search on
metadata['challenge_id'], re-checking every hit.processingandrequires_capturestayunknown. Refunds are found bymetadata.tollstile_charge. - Search lag risk: a Search miss younger than
searchLagMsstaysunknown. If Search lags longer than that, reconciliation releases a charge whose PaymentIntent exists: the payer is charged and the ledger says released. KeepsearchLagMsgenerous and reconcile Stripe payouts against the ledger.
Stored data. { challengeId, amount, currency }. The Shared Payment Token is a bearer token and never reaches the ledger: it stays in process memory between verification and settlement in the same request. Reconciliation never re-settles an upfront charge; it looks it up.
mppTempo(options)
| Option | Default | Purpose |
|---|---|---|
realm, secret | required | Challenge realm and HMAC secret(s) |
rpcUrl, chainId | required | Tempo JSON-RPC (4217 mainnet, 42431 Moderato) |
recipient | required | Payee address |
token | required | TIP-20 { address, code } (6 decimals) |
denomination | required | Price currency the token is worth at par, e.g. "USD". Other currencies get no offer |
modes | ["pull"] | Add "push" to accept transfers the payer already broadcast (below) |
splits | none | (amount) => [{ recipient, amount, memo? }] in base units; the sum must stay below the total |
validityMarginMs | 60 seconds | Block-timestamp skew allowed past a transaction's validBefore |
fetch, clock | global, system | Injection points |
| Capability | Value | Why |
|---|---|---|
flows | authorization | A pull transaction can be broadcast any time before validBefore, so it is broadcast only after the handler succeeds |
authorization | single | One transfer |
refund · partialRefund | false | Refunding would need a merchant signing key |
variableAmount | false | The signed amount is fixed |
quotes | true | Carried in opaque |
lookup | true | eth_getTransactionReceipt by transaction hash |
- Binding on-chain: every challenge carries
methodDetails.memo, derived from the realm and the quote, and the primary transfer must betransferWithMemowith it. One on-chain payment cannot satisfy two challenges. - Pull verification (offline): strict decoding of the
0x76transaction, sender recovery, chain id,validBeforein the future and not after the challenge expiry, and calls that are exactly the required transfers. Fee sponsorship, key authorizations, authorization lists, non-secp256k1 signatures, and extra calls are refused. - Settlement:
eth_sendRawTransactionSyncafter the handler. Rebroadcasting the same bytes cannot transfer twice. A lost answer staysunknownuntil the transaction can no longer be included (validBeforeplus the margin); only then is it rejected. - Residual risk: between verification and broadcast, the payer can spend the nonce or the balance. The handler has then run unpaid; the charge ends
failed/completedandonEventreportsSETTLEMENT_REJECTED. - Push mode: the payer broadcasts and sends the hash; the receipt's transfer logs are checked at verification, and the payment has already moved when the handler runs. Core records it as
upfront, settled before the handler. The rail cannot refund, so a failed handler leaves the chargesettled/failedwith aREFUND_REJECTEDevent, and reconciliation skips it. Enable push only if you will refund those payments yourself.
Stored data and redaction. The signed pull transaction is stored in the authorization's data so settlement survives a crash. redact drops it once a charge is final; the hash and validBefore stay for lookup. A released charge keeps it, so the same credential can be retried.
mppTempoSession(options) — experimental
Options: realm, secret, rpcUrl, chainId, recipient (the channel payee), token, denomination, escrow (defaults to the TIP-20 channel escrow precompile), operator (defaults to none), fetch, clock.
| Capability | Value | Why |
|---|---|---|
flows | upfront | Voucher coverage can only be checked at settlement, so an uncovered call is refused before it runs |
authorization | reusable | The authorization is the channel; its limit is the deposit minus what was settled on-chain when first seen |
refund | true | Nothing is captured per charge; a refund removes the charge from consumption |
partialRefund · variableAmount | false | Not implemented |
lookup | true | Settle and refund have no external effect, so lookup is exact |
- Verification accepts
vouchercredentials: the channel descriptor, the EIP-712 voucher signature, and live channel state (exists, no close requested, voucher within the deposit). - Settlement accepts a voucher only if it covers everything consumed and reserved on the channel, including in-flight charges.
settleddoes not mean funds moved. It means you hold a payer-signed voucher. Funds move when you close the channel with the calldata fromtempoSessionClose({ authorization, charges, settledOnChain? }), submitted from the payee account with your own wallet.- Guarantee gap: a payer can request a close and withdraw after the escrow's grace period (15 minutes in the reference contract). Anything not captured by then is lost, even though the ledger says
settled. Watch forCloseRequestedand close promptly. - Not supported:
open,topUp, andclosecredentials (the payer opens and funds the channel on-chain first), session protocol v1, top-ups raising the ledger limit, and streaming metering. - Why experimental: vouchers pass from verification to settlement in process memory, which rules out the
authorizationflow and variable prices until core can persist per-charge proofs.
Verification status
Tested only against in-process fakes and published vectors. Not verified against Stripe or a Tempo node.
- Challenge ids: mppx 0.9.3's HMAC test vectors. JCS: RFC 8785 examples.
- Stripe: an in-memory Stripe with idempotency replay and conflicts, Search visibility lag, refunds, declines, 5xx responses, and dropped connections.
- Tempo: transactions built and signed in the tests, and a fake JSON-RPC node. No bytes from a real Tempo client were used.
To verify live: pay a $0.50+ route with a Stripe test-mode Shared Payment Token and confirm a succeeded PaymentIntent with metadata.challenge_id, force a handler failure and confirm the refund, and run npx mppx@latest validate <url>. On Moderato, pay a Tempo challenge with the mppx client in pull mode and find the transaction hash from the receipt on the explorer. For sessions, open a v2 channel with the mppx session client, send vouchers, and submit tempoSessionClose() calldata.
Retries and payer identity
Stripe charge and Tempo charge return their challenge ID as the protocol idempotency key. The client's Idempotency-Key (or MCP key) overrides it. After successful fulfillment and settlement, replaying the credential normally returns 409 already_paid, not a fresh 402. Running charges return 409 request_in_progress; unknown outcomes return 503 payment_outcome_unknown. Verification can still reject an expired or invalid credential before lookup.
| Rail | Payer scope | Consequence |
|---|---|---|
| Stripe charge | stripe:<challengeId> | A new challenge is a new payer scope; the same client key does not deduplicate across challenges. |
| Tempo charge | did:pkh:eip155:<chainId>:<sender> | A client key can identify the same operation across fresh proofs from that payer. |
| Tempo session | did:pkh:eip155:<chainId>:<payer> | Reusable; send a client key to avoid consuming another call on a retry. No challenge ID key is supplied by this rail. |
A refunded single-use charge permits a new key attempt, but its old authorization remains single-use: a valid fresh authorization may be needed. See Idempotency.
x402
The x402 V2 rail. Agents pay per request in USDC or another EVM token; Tollstile verifies through a facilitator before the handler and settles after it succeeded.
L402
The L402 (Lightning) rail. The payer pays an invoice once, then presents the macaroon and preimage on every call until the credential's value or lifetime runs out.