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

MPP

Machine Payments Protocol rails — Stripe charge (Shared Payment Tokens), Tempo charge, and the experimental Tempo session — over HTTP and MCP.

Public Beta · early access
npm install tollstile @tollstile/mpp
import { 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.

RailNameMPP method / intentFlowStatus
mppStripe()mpp-stripestripe / charge (Shared Payment Tokens)upfrontImplemented, tested against fakes
mppTempo()mpp-tempotempo / charge (TIP-20 transfer, pull and push)authorizationImplemented, tested against fakes
mppTempoSession()mpp-tempo-sessiontempo / session v2 (payment channels, voucher action)upfrontExperimental

Wire format

Shared by every MPP rail.

  • Challenge: one WWW-Authenticate: Payment id, realm, method, intent, request, expires, opaque per rail. request and opaque are base64url of RFC 8785 (JCS) JSON. expires is the quote's expiry. The 402 body's accepts[].details carries the same challenge as an object.
  • Binding: id is an HMAC-SHA256 over the challenge with secret, 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 echoed challenge and the method's payload; over MCP, _meta["org.paymentauth/credential"]. Verification checks the id, realm, expiry, quote, that the echoed request is 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-Receipt plus Cache-Control: private over HTTP; _meta["org.paymentauth/receipt"] over MCP.
  • MCP challenge: JSON-RPC error -32042 with data.challenges when the client declared capabilities.experimental.payment. Verification failures also use -32042, with data.failure.reason.
  • Proof id: the challenge id, single-use through the ledger; for sessions, the channel id.

mppStripe(options)

OptionDefaultPurpose
realm, secretrequiredChallenge realm and HMAC secret(s)
secretKeyrequiredStripe API key, sent only as Authorization: Bearer
networkIdrequiredStripe 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
searchLagMs10 minutesHow long a Stripe Search miss is not trusted after an ambiguous settlement
apiBase, fetch, clockStripe, global, systemInjection points
CapabilityValueWhy
flowsupfrontConfirming a PaymentIntent captures immediately; there is no hold to release
authorizationsingleOne token, one payment
refund · partialRefundtrue · trueStripe Refunds API
variableAmountfalseThe token is granted for the challenged amount
quotestrueCarried in opaque
lookuptrueBy 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 one 503, 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. processing and requires_capture stay unknown. Refunds are found by metadata.tollstile_charge.
  • Search lag risk: a Search miss younger than searchLagMs stays unknown. If Search lags longer than that, reconciliation releases a charge whose PaymentIntent exists: the payer is charged and the ledger says released. Keep searchLagMs generous 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)

OptionDefaultPurpose
realm, secretrequiredChallenge realm and HMAC secret(s)
rpcUrl, chainIdrequiredTempo JSON-RPC (4217 mainnet, 42431 Moderato)
recipientrequiredPayee address
tokenrequiredTIP-20 { address, code } (6 decimals)
denominationrequiredPrice 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)
splitsnone(amount) => [{ recipient, amount, memo? }] in base units; the sum must stay below the total
validityMarginMs60 secondsBlock-timestamp skew allowed past a transaction's validBefore
fetch, clockglobal, systemInjection points
CapabilityValueWhy
flowsauthorizationA pull transaction can be broadcast any time before validBefore, so it is broadcast only after the handler succeeds
authorizationsingleOne transfer
refund · partialRefundfalseRefunding would need a merchant signing key
variableAmountfalseThe signed amount is fixed
quotestrueCarried in opaque
lookuptrueeth_getTransactionReceipt by transaction hash
  • Binding on-chain: every challenge carries methodDetails.memo, derived from the realm and the quote, and the primary transfer must be transferWithMemo with it. One on-chain payment cannot satisfy two challenges.
  • Pull verification (offline): strict decoding of the 0x76 transaction, sender recovery, chain id, validBefore in 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_sendRawTransactionSync after the handler. Rebroadcasting the same bytes cannot transfer twice. A lost answer stays unknown until the transaction can no longer be included (validBefore plus 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/completed and onEvent reports SETTLEMENT_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 charge settled/failed with a REFUND_REJECTED event, 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.

CapabilityValueWhy
flowsupfrontVoucher coverage can only be checked at settlement, so an uncovered call is refused before it runs
authorizationreusableThe authorization is the channel; its limit is the deposit minus what was settled on-chain when first seen
refundtrueNothing is captured per charge; a refund removes the charge from consumption
partialRefund · variableAmountfalseNot implemented
lookuptrueSettle and refund have no external effect, so lookup is exact
  • Verification accepts voucher credentials: 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.
  • settled does not mean funds moved. It means you hold a payer-signed voucher. Funds move when you close the channel with the calldata from tempoSessionClose({ 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 for CloseRequested and close promptly.
  • Not supported: open, topUp, and close credentials (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 authorization flow 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.

RailPayer scopeConsequence
Stripe chargestripe:<challengeId>A new challenge is a new payer scope; the same client key does not deduplicate across challenges.
Tempo chargedid:pkh:eip155:<chainId>:<sender>A client key can identify the same operation across fresh proofs from that payer.
Tempo sessiondid: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.

On this page