Elixir implementation of the Machine Payments Protocol (MPP) — HTTP 402 payment middleware for AI agents and machine-to-machine commerce.
MPP is an open standard for machine-to-machine payments via HTTP 402, co-developed by Stripe and Tempo Labs. It enables any API to charge per-request without user accounts, API keys, or signup flows.
Payment is authentication. An agent hits your endpoint, gets a 402 challenge, pays, and receives the response — all in a single HTTP roundtrip.
Client Server
│ │
│─── GET /api/data ──────────────────────►│
│ │
│◄── 402 Payment Required ───────────────│
│ WWW-Authenticate: Payment │
│ (challenge with price + method) │
│ │
│ [Client fulfills payment] │
│ │
│─── GET /api/data ──────────────────────►│
│ Authorization: Payment <credential> │
│ │
│◄── 200 OK + Payment-Receipt ───────────│
│ (resource + proof of payment) │
│ │
Mount MPP.Plug in your Phoenix router to gate any endpoint behind payment:
defmodule MyAppWeb.Router do
use MyAppWeb, :router
pipeline :paid do
plug MPP.Plug,
secret_key: "your-hmac-secret",
realm: "api.example.com",
method: MPP.Methods.Stripe,
amount: "5000",
currency: "usd",
method_config: %{
"stripe_secret_key" => "sk_test_...",
"network_id" => "profile_1Mqx...",
"payment_method_types" => ["card"]
}
end
scope "/premium", MyAppWeb do
pipe_through [:api, :paid]
get "/data", DataController, :show
end
endpipeline :paid_tempo do
plug MPP.Plug,
secret_key: "your-hmac-secret",
realm: "api.example.com",
method: MPP.Methods.Tempo,
amount: "1000000",
currency: "0x...(pathUSD token address)",
recipient: "0x...your-address",
method_config: %{
"rpc_url" => "https://rpc.tempo.xyz",
"chain_id" => 4217,
"fee_payer" => true,
# Sponsorship requires an explicitly selected atomic store.
# ConCache is single-node; use one shared backend across nodes that sponsor
# the same wallet.
"store" => MPP.Tempo.ConCacheStore,
# Either use a local fee-payer key...
"fee_payer_private_key" => "0x...",
# ...or delegate co-signing to a hosted eth_fillTransaction endpoint.
# "fee_payer_url" => "https://sponsor.example.com",
# "sponsor_budget_id" => "0x...hosted-sponsor-wallet",
"fee_token" => "0x...(fee token address)",
"fee_payer_policy" => %{
"max_in_flight_total_fee" => 500_000_000_000_000_000,
"max_in_flight_reservations" => 100
},
"wait_for_confirmation" => false,
"memo" => "0x...(optional 32-byte memo)"
}
endpipeline :paid_evm do
plug MPP.Plug,
secret_key: "your-hmac-secret",
realm: "api.example.com",
method: MPP.Methods.EVM,
amount: "1000000",
currency: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
recipient: "0x...your-address",
method_config: %{
"rpc_url" => "https://mainnet.infura.io/v3/YOUR_KEY",
"chain_id" => 1
}
endCurrency is the ERC-20 token contract address (e.g., USDC above). For native ETH, use "ETH" or the zero address. "chain_id" is required — the EIP-155 chain ID of the target network (e.g. 1 for Ethereum mainnet). Hash credentials: the client broadcasts a transaction, then sends the hash. For Circle USDC/EURC, set "private_key" (server-only settlement key) to advertise type="authorization" and settle EIP-3009 transferWithAuthorization with challengeHash as the nonce.
pipeline :paid_solana do
plug MPP.Plug,
secret_key: "your-hmac-secret",
realm: "api.example.com",
method: MPP.Methods.Solana,
amount: "10000000",
currency: "sol",
recipient: "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU",
method_config: %{
"rpc_url" => "https://api.mainnet-beta.solana.com",
"network" => "mainnet"
}
endCurrency is "sol" for native SOL (amount in lamports) or a base58 mint address for SPL tokens. Pull mode (type="transaction") sends signed transaction bytes for the server to broadcast; push mode (type="signature") sends a confirmed signature. Set "fee_payer" => true with "fee_payer_private_key" to co-sign as fee payer. Optional "splits" (at most 8) add extra payment legs. Set "confidential" => true (Token-2022 mints only) to require the confidential transfer profile: the client submits a type="bundle" credential whose final transaction carries the single Token-2022 confidential Transfer/TransferWithFee, and the server confirms the amount by decrypting the recipient pending-balance delta with "recipient_elgamal_secret_key".
Hash-only charges. Call MPP.Methods.NearIntents.quote/1 to mint a wet EXACT_OUTPUT 1Click quote, then mount the returned amount, origin asset, deposit address, and method_config on MPP.Plug. The client deposits on the origin chain and retries with type="hash". Verification waits for 1Click SUCCESS (and can check EVM origin RPC when "origin_rpc_url" is set). A configured "store" must implement atomic MPP.Tempo.Store.update/3. There is no Intents testnet — live tests use production 1Click plus historical deposits. Optional partner JWT: "one_click_jwt" / NEAR_INTENTS_ONE_CLICK_JWT.
{:ok, quote} =
MPP.Methods.NearIntents.quote(%{
"origin_asset" => "eip155:1/erc20:0xdac17f958d2ee523a2206206994597c13d831ec7",
"origin_asset_id" => "nep141:eth-usdt.omft.near",
"destination_asset" => "tron:mainnet/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t",
"destination_asset_id" => "nep141:tron-usdt.omft.near",
"destination_recipient" => "TJ4FU4NFMqFDtcLYxFnJvfv3rWfLN9vCB7",
"amount_out" => "1000000",
"refund_to" => "0x...",
"deadline" => deadline
})
plug MPP.Plug,
secret_key: "your-hmac-secret",
realm: "api.example.com",
method: MPP.Methods.NearIntents,
amount: quote.amount,
currency: quote.currency,
recipient: quote.recipient,
method_config: quote.method_configReplay protection is on by default. When you don't configure a "store", MPP uses the app-started MPP.Tempo.ConCacheStore so each transaction hash is accepted only once out of the box. For multi-node deployments, configure method_config["store"] with a shared MPP.Tempo.Store implementation (Redis, Postgres, …); a configured store must implement the atomic check_and_mark/2. When multiple endpoints share one ConCacheStore, add key_prefix: "tenant:" in the store opts to namespace dedup keys. Pass store: false (Plug opt) or "store" => false (method_config) to explicitly opt out of dedup — not recommended.
Offer multiple payment options in a single 402 response — the agent picks whichever it can pay with:
pipeline :paid_multi do
plug MPP.Plug,
secret_key: "your-hmac-secret",
realm: "api.example.com",
methods: [
[
method: MPP.Methods.Stripe,
amount: "5000",
currency: "usd",
method_config: %{
"stripe_secret_key" => "sk_test_...",
"network_id" => "profile_1Mqx..."
}
],
[
method: MPP.Methods.Tempo,
amount: "5000000",
currency: "0x...(pathUSD)",
recipient: "0x...",
method_config: %{"rpc_url" => "https://rpc.tempo.xyz", "chain_id" => 4217}
]
]
endRequests without payment get a 402 Payment Required with a challenge. Requests with a valid Authorization: Payment credential pass through with a Payment-Receipt header and the receipt in conn.assigns[:mpp_receipt].
Each route can have its own pricing — just mount MPP.Plug with different amount/currency per pipeline or scope.
Set intent: "subscription" with period_unit and period_count to use the
shared MPP.Intents.Subscription schema. MPP.Methods.Stripe activates a
constrained fixed-price Stripe subscription, verifies its paid first invoice,
and records the activation durably; MPP.Methods.Stripe.Subscription.process_invoice/3
maps paid renewal cycle invoices onto canonical billing periods with atomic
event/invoice dedup, and cancel/2 schedules Stripe cancellation at the end of
the last paid period. MPP.Methods.Tempo activates a scoped access key, settles
the first period, and exposes MPP.Methods.Tempo.Subscription.authorize/2 for
later renewals. Both methods use MPP.Subscription.ETSStore by default;
configure a shared MPP.Subscription.Store backend when renewals must
coordinate across nodes or survive restarts.
Today, monetizing an API means building a billing system: user accounts, API key provisioning, usage tracking, rate limiting, a pricing page, a dashboard. That's months of work before you earn a cent.
With MPP, you add one Plug to your router and your API charges per-request. No accounts. No API keys. No billing infrastructure. The payment is the authentication.
Use cases:
- Charge $0.01 per AI inference call
- Charge $0.50 per premium data query
- Charge $5.00 per document generation
- Different prices per route — one Plug per endpoint
For AI agents: Your API becomes callable by any agent with a wallet. No onboarding flow, no API key provisioning, no approval process. The agent discovers the price from the 402 response, pays, and gets the resource. That's it — your API just acquired a customer in one HTTP roundtrip.
- No user management. No accounts, no API keys, no dashboards, no onboarding. The 402 flow handles auth and billing in one protocol.
- Agent-native. AI agents can't click buttons or fill out forms. They can make HTTP requests and hold wallets. MPP meets agents where they are.
- Sticky by default. When your API is a line of code in a deployed system, the switching cost is engineering hours — not emotional preference.
- Payment-method agnostic. Stripe cards, stablecoins, on-chain tokens, Lightning — all pluggable via the same
Methodbehaviour.
| Method | Protocol | Settlement | Status |
|---|---|---|---|
| Stripe | MPP | Fiat (cards, wallets) | v0.1.0 |
| Tempo | MPP | Stablecoins (TIP-20) | v0.2.0 |
| EVM | MPP | Any EVM chain (ETH, USDC, ERC-20) | v0.3.0 |
| Solana | MPP | Native SOL and SPL tokens (incl. Token-2022 confidential) | v0.14.0 |
| NEAR Intents | MPP | Cross-chain deposits via 1Click (hash-only) | v0.14.0 |
| Lightning | MPP | Bitcoin (BOLT11) | Future |
The server can offer multiple payment methods in a single 402 response. The agent picks whichever it can pay with.
Tempo capabilities: Local or hosted fee-payer co-signing (server sponsors gas), fee-token allowlists, optimistic broadcast (respond before block inclusion), memo matching for transaction tagging, zero-amount proof credentials, delegated access-key proof authorization, opt-in presenter-identity binding for hash/transaction credentials, first-party machine-token (MPP Credits / machineUSD) charge payments via "machine_token_enabled", and pluggable dedup stores with a built-in ETS+TTL option via ConCache, including per-store key prefixes for shared-cache tenancy.
Tempo security note: Challenges expire by default. On routes without a configured static memo, Tempo payments must use challenge-bound attribution metadata; plain transfers are rejected by the hardened verifier. Sponsored transactions are bounded by fee-payer gas policy and returned hosted fee tokens are checked against the sponsor allowlist before broadcast. Setting "require_presenter_binding" => true in the Tempo method_config additionally requires hash/transaction credential presenters to prove control of the transfer sender's wallet with a "presenterSignature" (the proof path's EIP-712 envelope, signed by the sender wallet or an authorized access key; the client signs MPP.Methods.Tempo.Proof.hash/1 typed data) — closing the front-running residual documented in GHSA-34g7-vx6g-82mq. The requirement is advertised as "presenterBinding": true in the 402 method details. Opt-in because neither reference SDK binds the presenter on the hash path.
Tempo networks: Mainnet (chain ID 4217, rpc.tempo.xyz) | Testnet (Moderato) (chain ID 42431, rpc.moderato.tempo.xyz)
| Module | Purpose |
|---|---|
MPP.Plug |
Plug middleware — the main integration point |
MPP.Plug.Config |
Validated endpoint config (shared settings + method entries) |
MPP.Plug.MethodEntry |
Per-method config within a multi-method endpoint |
MPP.Challenge |
HMAC-SHA256 bound challenge creation/verification |
MPP.Credential |
Payment credential encoding/decoding |
MPP.Receipt |
Proof-of-payment receipt serialization |
MPP.Headers |
WWW-Authenticate (incl. multi-challenge), Authorization, Payment-Receipt headers |
MPP.AcceptPayment |
Accept-Payment client-preference header: parse, format, rank, apply_header |
MPP.Errors |
RFC 9457 Problem Detail error types (incl. session error types) |
MPP.Verifier |
Transport-neutral verification pipeline (HMAC, realm, expiry, request match, method.verify) |
MPP.JCS |
RFC 8785 JSON Canonicalization (MPP subset) for cross-SDK HMAC interop |
MPP.BodyDigest |
SHA-256 body digest compute/verify for request body binding |
MPP.Amount |
Amount/decimals helpers: parse_units, with_base_units, parse_dollar_amount |
MPP.Expires |
Expiration helpers: seconds, minutes, hours, days, weeks, months, years, assert! |
MPP.DID |
DID helpers for EVM credential sources |
MPP.Method |
Behaviour for pluggable payment methods |
MPP.Intents.Charge |
Charge intent request schema |
MPP.Intents.Session |
Session intent request schema (pay-as-you-go) |
MPP.Intents.Subscription |
Shared recurring-subscription intent schema |
MPP.Session.Channel |
Session channel state, balance, and action wire mapping |
MPP.Session.Voucher |
EIP-712 voucher typed data and signature verification |
MPP.Session.Payload |
Session credential payload schema (open / voucher / topUp / close) |
MPP.Session.Actions |
Session credential action handlers and per-channel balance tracking |
MPP.Session.Method |
use wrapper that dispatches verify/2 through session actions |
MPP.Session.Store |
Pluggable session-channel persistence |
MPP.Session.ETSStore |
ETS-backed default session store |
MPP.Subscription.Store |
Pluggable recurring-subscription persistence |
MPP.Subscription.ETSStore |
Application-started single-node subscription store |
MPP.Subscription.Record |
Persisted recurring-payment authority and settlement state |
MPP.Methods.Stripe |
Stripe SPT payment verification |
MPP.Methods.Stripe.Subscription |
Stripe fixed-price subscription activation, durable renewal (process_invoice/3), and period-end cancellation (cancel/2) |
MPP.Methods.Tempo |
Tempo on-chain TIP-20 transfer verification via onchain_tempo |
MPP.Methods.Tempo.Subscription |
Tempo access-key subscription activation, authorization, and renewal |
MPP.Methods.Tempo.KeyAuthorization |
Tempo subscription key-authorization wire codec and verifier |
MPP.Methods.Tempo.FeePayerPolicy |
Fee-payer gas and fee-token sponsorship policy |
MPP.Methods.Tempo.HostedFeePayer |
Hosted eth_fillTransaction fee-payer fill support |
MPP.Methods.Tempo.MachineToken |
Canonical first-party machine-token (MPP Credits) charge-route construction and match |
MPP.Methods.Tempo.Proof |
EIP-712 proof credentials for zero-amount Tempo flows |
MPP.Methods.Tempo.SessionReceipt |
Tempo session receipt wire format |
MPP.Methods.EVM |
Generic EVM on-chain transfer verification (any chain) via onchain |
MPP.Methods.EVM.Authorization |
EIP-3009 transferWithAuthorization settlement for Circle USDC/EURC |
MPP.Methods.Solana |
Solana native SOL and SPL token charge verification via cartouche |
MPP.Methods.NearIntents |
NEAR Intents hash-credential charges via 1Click Swap + origin RPC |
MPP.Tempo.Store |
Behaviour for pluggable transaction dedup stores |
MPP.Tempo.ConCacheStore |
Built-in ETS dedup store with TTL via ConCache |
MPP.Telemetry |
Server-side payment telemetry events for challenges, verification, and receipts |
MPP.Discovery.OpenApi |
OpenAPI 3.1.0 discovery document generation (x-payment-info, 402 responses; mix mpp.openapi) |
MPP.Discovery.PaymentInfo |
Parser/normalizer for the x-payment-info discovery extension |
MPP.Mcp |
MCP (JSON-RPC) transport: server adapter (init/1 + call/3), initialize capabilities/1, error codes, meta keys, client helpers |
MPP.Transports.JsonRpc |
Bare JSON-RPC transport: root-level _meta credential/receipt, -32042 challenges |
MPP.Transports.JsonRpc.Plug |
Plug adapter for JSON-RPC-over-HTTP payment verification |
MPP.Transports.WebSocket |
WebSocket adapter: handshake challenge, credential/receipt frames, JSON-RPC message frames |
MPP.Client.PaymentProvider |
Behaviour for client-side payment providers (supports?/3, pay/2) |
MPP.Client.MultiProvider |
Multi-provider dispatch with first-match routing |
MPP.Client.Providers.Tempo |
Built-in Tempo charge provider — chain-pinned, attribution-bound TIP-20 payments, including machine-token [approve, swapTo] when advertised |
MPP.Client.Providers.Stripe |
Built-in Stripe charge provider — Shared Payment Token creation |
MPP.Client.SelectionPolicy |
Transport-neutral challenge selection/ordering (default: server offer order) |
MPP.Client.Req |
Payment-aware Req plugin — 402 detect, pay, retry (attach/2) |
MPP.Client.Transport |
Client transport behaviour — 402 detection, challenge fetch, credential attach |
MPP.Client.Transport.HTTP |
HTTP transport over Req |
MPP.Client.Transport.MCP |
MCP/JSON-RPC transport: -32042 detection, challenge extract, _meta credential attach |
MPP.Client.Transport.JsonRpc |
Bare JSON-RPC transport: -32042 detection, root-level _meta credential attach |
MPP.Client.Transport.WebSocket |
WebSocket transport: challenge frames, Payment credential frames, retry/backoff |
MPP.Client.MCP |
Payment-aware MCP client — select, approve, pay, retry the tool call once |
MPP.Client.AcceptPolicy |
Gates Accept-Payment header injection on outgoing requests |
provider =
MPP.Client.MultiProvider.new([
{MPP.Client.Providers.Tempo,
%{
private_key: tempo_private_key,
rpc_url: "https://rpc.tempo.xyz",
expected_chain_id: 4217,
client_id: "my-agent"
}},
{MPP.Client.Providers.Stripe,
%{
secret_key: stripe_secret_key,
payment_method: "pm_..."
}}
])
Req.new()
|> MPP.Client.Req.attach(provider: provider)
|> Req.get(url: "https://api.example.com/resource")MPP.Client.Req intercepts HTTP 402, pays, and retries with Authorization: Payment.
Provider credentials and endpoints are passed explicitly; the providers do not read
application configuration or environment variables. The Tempo provider verifies that
the RPC serves the challenge's advertised chain before signing and automatically creates
the challenge-bound attribution memo required by routes without a static memo.
A payment credential must never be created or attached after a redirect changed the
request origin — Req follows redirects by default. MPP.Client.Req.attach/2 refuses
that path (:cross_origin_redirect, mpp-rs #379). Callers that drive
MPP.Client.Transport.HTTP themselves must apply the same rule: do not call
set_credential/2 on a request whose origin (scheme/host/port) differs from the
URL the caller asked for.
client = MPP.Client.MCP.new(provider: my_provider)
MPP.Client.MCP.call(client, request, &MyTransport.send/1)MPP.Client.MCP does the same pay-and-retry over JSON-RPC: it detects -32042,
selects a challenge, asks on_payment_required for approval, pays, and retries
once with the credential at params._meta["org.paymentauth/credential"].
On the server side, MPP.Mcp.init/1 validates the transport config and
MPP.Mcp.call/3 gates tool calls with replay dedup. Merge
MPP.Mcp.capabilities(config) into the initialize result to advertise the
configured payment methods, their intents, and credential types under
experimental.payment.methods (draft-payment-transport-mcp-00).
Generic (non-MCP) JSON-RPC uses root-level _meta so params can be an array.
MPP.Transports.JsonRpc.Plug mounts on a Plug route; MPP.Client.Transport.JsonRpc
attaches the credential at _meta["org.paymentauth/credential"] on the request
envelope.
WebSocket endpoints use typed MPP frames (mpp-rs / alloy-transport-mpp).
MPP.Transports.WebSocket is library-agnostic: open/1 emits the handshake
challenge, handle_text/2 verifies a credential frame and then dispatches
JSON-RPC carried in message frames. MPP.Client.Transport.WebSocket detects
challenge frames and attaches Payment <base64url> credential frames.
MPP.Client.Transport.WebSocket.Retry matches upstream reconnect posture:
capped exponential backoff, fatal latch on protocol errors, and no second
payment after a drop that left a credential unacknowledged.
def deps do
[
{:mpp, "~> 0.16.0"}
]
endonchain, onchain_tempo, and con_cache are pulled in automatically — no extra setup for EVM, Tempo, or the built-in MPP.Tempo.ConCacheStore dedup store.
Strip0x — blockchain tools API using MPP with Tempo payments. $0.0001 per paid request (100 base units USDC.e on Tempo mainnet).
# Free endpoint (no payment needed)
curl "https://strip0x.com/api/hex/encode?value=hello"
# See the 402 challenge on a paid endpoint
curl -i "https://strip0x.com/api/address/validate?address=0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"
# Pay and get the response (~2s round-trip including on-chain settlement)
tempo request -t -X GET "https://strip0x.com/api/address/validate?address=0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"
# Machine-readable discovery (OpenAPI 3.1 with x-payment-info extensions)
curl https://strip0x.com/openapi.jsonObserved latency: ~2s end-to-end for a paid request (402 challenge + Tempo on-chain TIP-20 transfer + credential retry). Free endpoints respond in ~70ms (network only — business logic is sub-10μs on the BEAM).
Try it and open an issue if anything breaks.
No credentials needed — mix mpp.demo starts a demo 402 server on port 4402
(--port to override) with a toy method that accepts a magic "demo-token"
payload. The startup banner prints copy-paste curl commands for the full
challenge → pay → receipt flow. Requires Bandit ({:bandit, "~> 1.10", only: :dev}
when using mpp as a dependency).
GitHub Actions workflows (Elixir/OTP pinned via .tool-versions, so CI never
drifts from local mix format):
-
CI (
.github/workflows/ci.yml) — runs on every push/PR todevelopmentandmain: format check,--warnings-as-errorscompile, Credo strict, Doctor, Sobelow, tests with a 95% coverage gate, and Dialyzer. Mirrorsmix precommit.full. -
Integration (
.github/workflows/integration.yml) — runs the credential-gated:integrationsuite nightly (and on PR / manual dispatch). These live round-trips catch the bug class unit tests are blind to (wrong gas limit, wrong request shape, on-chain accounting drift). It requires the following repo secrets — when any are absent the suite flunks loudly rather than reporting a green 0-test run:Secret Purpose TEMPO_RPC_URLModerato testnet RPC ( https://rpc.moderato.tempo.xyz)STRIPE_SECRET_KEYStripe test-mode secret key ( sk_test_…)ETH_SEPOLIA_RPC_URL/ETH_SEPOLIA_PRIVATE_KEYSepolia RPC + funded key EVM_RPC_URL/EVM_PRIVATE_KEYGeneric EVM RPC + funded key (falls back to Sepolia) -
Mutation security (
.github/workflows/mutation-security.yml) — nightly andworkflow_dispatchonly (not on PRs). Runsmix mutation.security, which applies each payment-security mutant, compiles it, and runs its tests. A surviving canary fails the job. Kept out ofmix ci/mix precommit.full.
A further workflow, Code Scanning (.github/workflows/code-scanning.yml), uploads
Sobelow findings to the Security → Code scanning tab (CodeQL has no Elixir support).
Security vulnerabilities should be reported privately — see SECURITY.md.
- MPP Specification — IETF draft, core protocol
- x402 Documentation — On-chain payment standard
- Stripe MPP Announcement — Stripe's agent commerce vision
- mpp.dev — Protocol overview and SDK links
MIT — see LICENSE for details.