Nothing else changes. A plan works on both, a delegation works on both, and the
credits burned for a request are identical either way.
Seller: accept MPP on a route
Add"mpp": True to a route the FastAPI middleware already protects. With it
unset, the x402 path is untouched.
Binding the challenge to the request body
{"bind_body": True} seals a sha-256=<base64> digest of the request body into
the challenge, so the paid retry must carry the same bytes:
bind_body is the only key the option accepts, and a typo raises at startup
rather than resolving to False: {"bindBody": True} would otherwise turn the
binding off silently, which is not a missing nicety — see the paragraph below for
what an unbound challenge lets a buyer do.
A request with no body binds the digest of zero bytes rather than nothing at
all. Leaving it unbound would let a buyer mint against an empty request and
attach any body they liked to the paid retry — the backend skips the comparison
when the challenge carries no digest, so “unbound” means the buyer decides
whether bind_body applies.
Reading the body in the middleware consumes the ASGI receive channel; the
middleware re-arms it, so your handler still sees exactly what the buyer sent.
No parser hook is needed (the TypeScript SDK’s captureRawBody has no
counterpart here).
What the middleware guarantees
- Single use. A credential buys exactly one response. A replay is answered
with a 402 carrying
code: "BCK.MPP.0003"and a fresh challenge, so the buyer can still make progress by paying again. - No concurrent double-spend within the process. A second request presenting
a credential already in flight gets
409 Conflict. Verification burns nothing and settlement is idempotent, so without this guard N concurrent requests would each be served for a single burn. - Settlement only on a 2xx. A handler that fails or refuses is never settled, and the credential stays unspent.
Hooks
PaymentMiddlewareOptions works the same on both protocols, with one deliberate
difference: on MPP, on_payment_error notifies and the middleware keeps
ownership of the response, because a 402 without a fresh challenge leaves the
buyer unable to make progress. A hook that returns a Response still wins.
on_payment_error is not called for the credential-less opening request —
that is the first turn of every healthy payment cycle, and notifying there would
drown the rejections the hook exists to surface. It is called when an
Authorization header arrives carrying no Payment scheme, which means an
intermediary is rewriting it and the buyer is stuck in a silent retry loop.
on_after_settle fires for all three settlement outcomes, so a ledger built on
it can count them apart:
Only the first row is a measurement. The charged amount is recomputed on the
settling request, so whenever
credits is a callable it is free to differ from
what the challenge sealed on the request that minted the credential — do not
record rows two and three as if the backend had confirmed them.
The third row is the one worth wiring: the resource was delivered and the seller
was not paid. Were it reported only as an absence, it would be indistinguishable
from a request that was never an MPP request at all.
Buyer: pay an MPP endpoint
payments.mpp.fetch pays a challenged endpoint with the delegation you already
use for x402. No new plan, no new delegation, no new credential.
requests.request
unchanged (headers, json, data, params, timeout, stream, …).
Reading the result honestly
ok=True, paid=False, credentials_presented=1 is a routine outcome, not an
exotic one: a seller whose handler streams has already sent its headers when
settlement runs, so no receipt can be attached. The credits were burned. Never
read that combination as “the payment did not happen” and retry.
response.ok is not optional either — a returned result does not mean the
request was paid for. Three dead ends return the 402 rather than raising: no
usable challenge on it, a retryable rejection with no challenge to retry
against, and the one re-challenge cycle spent.
The retry contract
At most one re-challenge cycle is followed. On a retry-turn 402:- A code decides alone.
BCK.MPP.0004(expired) andBCK.MPP.0005(body digest mismatch) are retried against the fresh challenge; every other code — including a non-BCK.MPP.*one — is terminal. - With no code, freshness decides. A challenge whose
iddiffers from the one just presented is a real re-challenge and is retried once. The identical id replayed, an unparseable challenge, or an unreadable body are terminal.
is_retryable_mpp_code(code) rather than hardcoding that list.
Errors, and knowing whether money left
mpp_spend_of returns a report only when at least one credential was
presented, so a non-None result always means money may have left. It reads the
report off PaymentsError too — a max_credits or plan_id guard can fire on
the re-challenge turn, after a credential has already gone out.
Request bodies must be replayable
A generator, iterator or file-likedata= cannot be resent, so it is refused
with a PaymentsError at the point a retry would reuse it — never before the
first request. An endpoint that never challenges sends such a body exactly once,
exactly like a plain requests call.
max_credits is a budget for the call
A seller names the price, and a re-challenge names it again. max_credits caps
the sum, so a re-challenge cannot collect the cap twice.
Lower-level API
payments.mpp also exposes the three backend routes directly, for a seller not
using the FastAPI middleware:
issue_challenge returns a distinct challenge even for identical inputs —
the id doubles as the burn idempotency key, so two requests sharing one would
settle as a single burn.
What the SDK never holds
The MPP signing secret and receipt signing live only in the Nevermined backend. The SDK renames headers and forwards opaque strings; it reads exactly one field out of a credential —challenge.id — because enforcing single use
needs a stable identity and the header bytes are not one (they are
buyer-malleable, and the backend collapses every variant onto a single burn).