diff --git a/api-reference/python/installation.mdx b/api-reference/python/installation.mdx
index 53541f09..99eb8828 100644
--- a/api-reference/python/installation.mdx
+++ b/api-reference/python/installation.mdx
@@ -40,7 +40,8 @@ poetry add payments-py
### With Optional Dependencies
-For FastAPI/x402 middleware support:
+For FastAPI/x402 middleware and MCP server support (installs `fastapi`,
+`starlette`, and `uvicorn`):
```bash
# Using pip
diff --git a/api-reference/python/mpp-module.mdx b/api-reference/python/mpp-module.mdx
new file mode 100644
index 00000000..86d09aa1
--- /dev/null
+++ b/api-reference/python/mpp-module.mdx
@@ -0,0 +1,295 @@
+---
+title: "MPP (Machine Payments Protocol)"
+description: "Accept and pay MPP (Machine Payments Protocol) with the Python SDK"
+icon: "handshake"
+---
+
+MPP is a second payment framing over the unchanged Nevermined core: the **same
+plan, the same delegation and the same credit burn** as [x402](/api-reference/python/x402-module),
+negotiated with different HTTP headers.
+
+| | x402 | MPP |
+|---|---|---|
+| Server asks for payment | `payment-required` header on a 402 | `WWW-Authenticate: Payment …` on a 402 |
+| Client presents payment | `payment-signature` header | `Authorization: Payment …` |
+| Server confirms | `payment-response` header | `Payment-Receipt` header |
+
+Nothing else changes. A plan works on both, a delegation works on both, and the
+credits burned for a request are identical either way.
+
+
+The MPP surface may change in a minor release. It is additive and default
+off — an application that does not opt in is unaffected.
+
+
+---
+
+## 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.
+
+```python
+from fastapi import FastAPI, Request
+from payments_py import Payments, PaymentOptions
+from payments_py.x402.fastapi import PaymentMiddleware
+
+app = FastAPI()
+payments = Payments.get_instance(PaymentOptions(nvm_api_key="nvm:..."))
+
+app.add_middleware(
+ PaymentMiddleware,
+ payments=payments,
+ routes={
+ # Accepts BOTH protocols. The 402 advertises an MPP challenge and the
+ # x402 payment-required header, so either buyer can pay it.
+ "POST /ask": {"plan_id": PLAN_ID, "credits": 2, "mpp": True},
+ },
+)
+
+
+@app.post("/ask")
+async def ask(request: Request):
+ context = request.state.payment_context
+ # context.mpp is present only when the request was paid over MPP. It is an
+ # MppPaymentFraming with three attributes: credential, resource, http_verb.
+ return {"answer": "...", "paid_over": "mpp" if context.mpp else "x402"}
+```
+
+### Binding the challenge to the request body
+
+`{"bind_body": True}` seals a `sha-256=` digest of the request body into
+the challenge, so the paid retry must carry the same bytes:
+
+```python
+routes={"POST /ask": {"plan_id": PLAN_ID, "credits": 2, "mpp": {"bind_body": True}}}
+```
+
+`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.
+
+
+They live in memory. A multi-worker `uvicorn`/`gunicorn` deployment is
+already several processes, so a credential can be replayed once per worker.
+Deployments that need a hard guarantee must add a shared store (e.g. Redis);
+this package does not provide one.
+
+
+### 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:
+
+| Outcome | `credits` | Third argument |
+|---|---|---|
+| Settled, amount reported | what the backend says it **burned** | the settlement response |
+| Settled, no usable amount reported | the charged amount — a **guess**, and logged as one | the settlement response |
+| Unknown — may have burned | the charged amount | `MppSettlementOutcomeUnknown` |
+| Definitely not paid | `0` | `MppSettlementFailed` |
+
+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.
+
+```python
+from payments_py.mpp import MppFetchOptions
+from payments_py.x402.types import DelegationConfig
+
+result = payments.mpp.fetch(
+ "POST",
+ "https://agent.example/ask",
+ MppFetchOptions(
+ delegation_config=DelegationConfig(delegation_id=delegation_id),
+ plan_id=plan_id, # optional: refuse a challenge naming another plan
+ max_credits="10", # optional: budget for the WHOLE call
+ ),
+ json={"q": "hello"},
+)
+
+print(result.response.status_code, result.paid, result.receipt)
+```
+
+Keyword arguments beyond the options are handed to `requests.request`
+unchanged (`headers`, `json`, `data`, `params`, `timeout`, `stream`, …).
+
+### Reading the result honestly
+
+| Field | Meaning |
+|---|---|
+| `response` | The final response — the paid one when a payment happened |
+| `settled` | The endpoint returned a receipt that decoded and does not state failure |
+| `paid` | `response.ok and settled` |
+| `credentials_presented` | How many credentials went on the wire (0, 1 or 2) |
+| `credits_presented` | Total credits the challenges named — an **upper bound** on what burned |
+| `receipt` | The decoded `Payment-Receipt`, when there was one |
+
+`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) and `BCK.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 `id` differs 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.
+
+Check `is_retryable_mpp_code(code)` rather than hardcoding that list.
+
+### Errors, and knowing whether money left
+
+```python
+import logging
+
+from payments_py.mpp import MppError, mpp_spend_of
+from payments_py.common.payments_error import PaymentsError
+
+logger = logging.getLogger(__name__)
+
+try:
+ result = payments.mpp.fetch(...)
+except PaymentsError as err:
+ # A guard refused the call: a bad argument, a challenge naming another
+ # plan, a body that cannot be replayed. Usually nothing was spent — but a
+ # max_credits or plan_id guard can fire on the RE-CHALLENGE turn, after a
+ # credential has already gone out, so the report is checked here too.
+ if mpp_spend_of(err):
+ logger.warning("guard fired after a credential was presented: %s", err)
+except MppError as err:
+ # What the wire actually said: a rejected credential, a malformed
+ # challenge, an MPP-disabled environment.
+ spend = mpp_spend_of(err)
+ if spend:
+ # A credential was already on the wire. Do NOT blindly retry.
+ logger.warning("up to %s credits may have burned", spend.credits_presented)
+```
+
+`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-like `data=` 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:
+
+```python
+from payments_py.mpp import IssueMppChallengeParams, RedeemMppParams
+
+issued = payments.mpp.issue_challenge(
+ IssueMppChallengeParams(
+ plan_id=PLAN_ID, credits=2, resource="/ask", http_verb="POST"
+ )
+)
+# → {"challenge": "Payment id=…", "id": "…"} — send as WWW-Authenticate
+
+verification = payments.mpp.verify_credential(
+ RedeemMppParams(credential=header, resource="/ask", http_verb="POST")
+) # burns nothing
+
+settlement = payments.mpp.settle_credential(
+ RedeemMppParams(credential=header, resource="/ask", http_verb="POST")
+) # burns; settling the same credential twice burns once
+```
+
+Each `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.
+
+
+`settle_credential` raises `MppSettlementOutcomeUnknownError` when the call
+ended without a definite answer — a read timeout, a connection torn down
+after the request was written, a 5xx/408, or a 2xx whose body could not be
+read. **The burn may already have committed.** Treating it like a definite
+failure silently corrupts your own accounting. A connect timeout, a refused
+connection and any 4xx are definite: nothing burned.
+
+Settlement gets a longer read deadline (90s) than every other SDK call,
+because it waits on an on-chain burn — a settle exceeding the generic 30s
+default was measured on staging. If it times out anyway, the recovery is to
+settle the same credential again: the challenge id doubles as the burn key,
+so a repeat settles onto the same single burn rather than charging twice.
+
+
+---
+
+## 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).
diff --git a/api-reference/python/payments-class.mdx b/api-reference/python/payments-class.mdx
index 03e09f76..476df2fc 100644
--- a/api-reference/python/payments-class.mdx
+++ b/api-reference/python/payments-class.mdx
@@ -27,11 +27,10 @@ Never commit your API key to version control. Use environment variables or a sec
```python
from payments_py import Payments, PaymentOptions
-# Initialize with API key and environment
+# The environment is derived from your API key's prefix — just pass the key.
payments = Payments.get_instance(
PaymentOptions(
- nvm_api_key="nvm:your-api-key-here",
- environment="sandbox"
+ nvm_api_key="sandbox:your-api-key-here",
)
)
@@ -40,6 +39,16 @@ print(f"Connected to: {payments.environment.backend}")
print(f"Account: {payments.account_address}")
```
+
+The `environment` option is **deprecated**. The SDK now derives the
+environment from the API-key prefix (`:`) — a key minted for
+sandbox starts with `sandbox:`, for production with `live:`, and so on. When
+the prefix is recognized it always wins; passing `environment` is ignored
+(with a warning). It is still accepted only as a fallback for local/custom
+keys whose prefix the SDK doesn't recognize (see
+[Custom Environment](#custom-environment)).
+
+
### Using Environment Variables
```python
@@ -49,7 +58,6 @@ from payments_py import Payments, PaymentOptions
payments = Payments.get_instance(
PaymentOptions(
nvm_api_key=os.getenv("NVM_API_KEY"),
- environment=os.getenv("NVM_ENVIRONMENT", "sandbox")
)
)
```
@@ -60,8 +68,8 @@ The `PaymentOptions` class accepts the following parameters:
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
-| `nvm_api_key` | `str` | Yes | Your Nevermined API key |
-| `environment` | `str` | Yes | Environment name (see below) |
+| `nvm_api_key` | `str` | Yes | Your Nevermined API key (its prefix sets the environment) |
+| `environment` | `str` | No | **Deprecated.** Derived from the API-key prefix; only used as a fallback for unrecognized (local/custom) prefixes (see below) |
| `app_id` | `str` | No | Application identifier |
| `version` | `str` | No | Application version |
| `api_version` | `str` | No | Backend API version sent as the `Nevermined-Version` header. Defaults to the version this SDK release targets (see below) |
@@ -73,8 +81,7 @@ from payments_py import Payments, PaymentOptions
payments = Payments.get_instance(
PaymentOptions(
- nvm_api_key="nvm:your-api-key",
- environment="sandbox",
+ nvm_api_key="sandbox:your-api-key",
app_id="my-app",
version="1.0.0",
headers={"X-Custom-Header": "value"}
@@ -93,28 +100,35 @@ different backend contract explicitly:
```python
payments = Payments.get_instance(
PaymentOptions(
- nvm_api_key="nvm:your-api-key",
- environment="sandbox",
+ nvm_api_key="sandbox:your-api-key",
api_version="1.1", # override the pinned backend API version
)
)
```
-See the [API versioning guide](/development-guide/api-versioning) for the
-resolution rules, and the [API changelog](/development-guide/api-changelog)
-for what changed in each version.
+See the [API versioning reference](https://nevermined.ai/docs/development-guide/api-versioning)
+for the list of versions and the changes between them.
## Environments
+The environment is determined by your API key's prefix — you don't select it
+explicitly. The prefix-to-environment mapping is:
+
+| API-key prefix | Environment |
+|----------------|-------------|
+| `sandbox:` | `sandbox` |
+| `live:` | `live` |
+| `sandbox-staging:` | `staging_sandbox` |
+| `live-staging:` | `staging_live` |
+
### Sandbox Environment (Testing)
-Use `sandbox` for development and testing:
+A key minted for sandbox starts with `sandbox:`:
```python
payments = Payments.get_instance(
PaymentOptions(
- nvm_api_key="nvm:your-api-key",
- environment="sandbox"
+ nvm_api_key="sandbox:your-api-key",
)
)
```
@@ -125,13 +139,12 @@ payments = Payments.get_instance(
### Live Environment (Production)
-Use `live` for production:
+A key minted for production starts with `live:`:
```python
payments = Payments.get_instance(
PaymentOptions(
- nvm_api_key="nvm:your-api-key",
- environment="live"
+ nvm_api_key="live:your-api-key",
)
)
```
@@ -142,7 +155,10 @@ payments = Payments.get_instance(
### Custom Environment
-For self-hosted or development setups:
+For self-hosted or local development setups, the API key's prefix won't be one
+the SDK recognizes, so it falls back to the (still-accepted) `environment`
+option. Pass `environment="custom"` to point the SDK at URLs from environment
+variables:
```python
import os
@@ -153,8 +169,8 @@ os.environ["NVM_PROXY_URL"] = "http://localhost:443"
payments = Payments.get_instance(
PaymentOptions(
- nvm_api_key="nvm:your-api-key",
- environment="custom"
+ nvm_api_key="local:your-api-key",
+ environment="custom", # fallback for unrecognized key prefixes
)
)
```
@@ -165,6 +181,8 @@ payments = Payments.get_instance(
|-------------|-------------|
| `sandbox` | Production sandbox (testing) |
| `live` | Production mainnet |
+| `staging_sandbox` | Staging sandbox |
+| `staging_live` | Staging mainnet |
| `custom` | Custom URLs via environment variables |
## Accessing Sub-APIs
diff --git a/api-reference/python/x402-module.mdx b/api-reference/python/x402-module.mdx
index 051879c9..7350b144 100644
--- a/api-reference/python/x402-module.mdx
+++ b/api-reference/python/x402-module.mdx
@@ -4,6 +4,10 @@ description: "Use x402 protocol for payment verification and settlement"
icon: "lock"
---
+> **Looking for MPP?** The Machine Payments Protocol is a second framing
+> over this same plan/credits/delegation core — see
+> [15. MPP Protocol](/api-reference/python/mpp-module).
+
This guide covers the x402 payment protocol for verifying permissions and settling payments.
## Overview