From ad46d056a90eccc91b03e355ca570ae05645c608 Mon Sep 17 00:00:00 2001
From: "nevermined-release-bot[bot]"
<245387585+nevermined-release-bot[bot]@users.noreply.github.com>
Date: Wed, 9 Sep 2026 14:39:06 +0000
Subject: [PATCH] docs: update Python SDK documentation for v1.18.0
- Updated from nevermined-io/payments-py@741b219836454f32638c96b4414e87bd062ec9ef
- SDK version: v1.18.0
- Target branch: main
- Generated documentation from tagged release
- Converted to Mintlify MDX format
---
api-reference/python/a2a-module.mdx | 58 ++++
api-reference/python/installation.mdx | 3 +-
api-reference/python/langchain-module.mdx | 111 ++++++++
api-reference/python/mpp-module.mdx | 330 ++++++++++++++++++++++
api-reference/python/payments-class.mdx | 62 ++--
api-reference/python/requests-module.mdx | 66 ++++-
api-reference/python/x402-module.mdx | 160 ++++++++++-
7 files changed, 765 insertions(+), 25 deletions(-)
create mode 100644 api-reference/python/mpp-module.mdx
diff --git a/api-reference/python/a2a-module.mdx b/api-reference/python/a2a-module.mdx
index 07620554..69f1a44d 100644
--- a/api-reference/python/a2a-module.mdx
+++ b/api-reference/python/a2a-module.mdx
@@ -304,6 +304,63 @@ async with httpx.AsyncClient() as client:
result = resp.json()
```
+### Using `PaymentsClient` (managed tokens)
+
+`payments.a2a["get_client"]` returns a `PaymentsClient` that mints and
+attaches the access token for you:
+
+```python
+client = payments.a2a["get_client"](
+ agent_base_url="http://agent-url/",
+ agent_id=agent_id,
+ plan_id=plan_ids[0],
+ delegation_config=DelegationConfig(delegation_id=delegation_id),
+)
+
+await client.send_message(params)
+```
+
+The client mints a **v2** token once and caches it for its lifetime — a v2
+token is a reusable bearer credential. Pass `token_version=3` to request the
+single-use, seller/resource-bound token instead:
+
+```python
+client = payments.a2a["get_client"](
+ agent_base_url="http://agent-url/",
+ agent_id=agent_id,
+ plan_id=plan_ids[0],
+ delegation_config=DelegationConfig(delegation_id=delegation_id),
+ token_version=3,
+)
+```
+
+On a v3 request the client also **binds** the token, to `agent_base_url` and
+`POST`. That default assumes the seller is this SDK's A2A server, which
+advertises `str(request.url)`; the backend compares origin + path, so the two
+agree. A seller that advertises something else — this SDK's FastAPI middleware
+advertises a *relative* `request.url.path` — would never match and the settle
+would fail, so pass the binding explicitly instead:
+
+```python
+client = payments.a2a["get_client"](
+ agent_base_url="http://agent-url/",
+ agent_id=agent_id,
+ plan_id=plan_ids[0],
+ delegation_config=DelegationConfig(delegation_id=delegation_id),
+ token_version=3,
+ resource="/a2a/", # whatever the seller advertises in its 402
+ http_verb="POST",
+)
+```
+
+A v3 token is consumed by the seller's first settle, so the client mints one
+**per paid request** and never caches it — replaying one fails with
+`BCK.X402.0059`. Which of the two behaviours applies is decided by the token
+that came back, not by `token_version`: a backend that predates v3 support
+strips the field silently and returns a (cached) v2 token. `clear_token()` is
+therefore a no-op on the v3 path. See
+[Access Token Versions](/api-reference/python/x402-module#access-token-versions-v2-and-v3).
+
## Hooks
Add custom logic at request lifecycle points:
@@ -338,6 +395,7 @@ result = PaymentsA2AServer.start(
| -32001 | 402 | Payment validation failed |
| -32001 | 402 | Agent ID missing from card |
| -32001 | 402 | Plan ID missing from card |
+| `BCK.X402.0059` | 4xx | v3 access token already spent — mint a new one (`AccessTokenAlreadyUsedError`) |
## Next Steps
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/langchain-module.mdx b/api-reference/python/langchain-module.mdx
index 423493ee..d8d958f7 100644
--- a/api-reference/python/langchain-module.mdx
+++ b/api-reference/python/langchain-module.mdx
@@ -389,8 +389,119 @@ with settlement_span(plan_ids=["plan-1"]) as span:
Span emission failures are caught internally — observability is best-effort and will not interfere with the payment flow.
+## Deep Agents
+
+[Deep Agents](https://docs.langchain.com/oss/python/deepagents/overview) is
+LangChain's agent *harness*: `create_deep_agent()` returns a compiled LangGraph
+graph with planning, a filesystem, and subagent delegation built in.
+
+**`requires_payment` needs no changes to work with it.** The decorator reads the
+token from `config["configurable"]["payment_token"]`, and LangGraph copies
+`configurable` down into subagent tool calls, so a paid tool keeps working when
+it sits behind a `task()` delegation:
+
+```
+main agent --task()--> research-sub --> market_research [PAID]
+ ^ |
+ +--------- x402 token supplied here ----------+
+ config.configurable.payment_token
+```
+
+That property is what makes the harness usable for monetized capabilities at
+all: a deep agent's premise is that the supervisor hands work to subagents, so
+if payment context did not survive the hop, every paid tool would have to sit on
+the main agent.
+
+```python
+from deepagents import create_deep_agent
+from langchain_core.runnables import RunnableConfig
+from langchain_core.tools import tool
+
+from payments_py.x402.langchain import PaymentRequiredError, requires_payment
+
+
+@requires_payment(payments=payments, plan_id=PLAN_ID, credits=5)
+def _market_research_paid(topic: str, config: RunnableConfig) -> str:
+ return run_analyst(topic)
+
+
+@tool
+def market_research(topic: str, config: RunnableConfig) -> str:
+ """Paid market research on a topic."""
+ try:
+ # `config` must be forwarded explicitly — the decorator reads the
+ # token from it.
+ return _market_research_paid(topic, config=config)
+ except PaymentRequiredError:
+ return "PAYMENT_REQUIRED: authorize and ask again."
+
+
+# The paid tool is given ONLY to the subagent, so every paid call crosses
+# a delegation boundary.
+graph = create_deep_agent(
+ model="openai:gpt-4o-mini",
+ tools=[],
+ subagents=[{
+ "name": "research-sub",
+ "description": "Performs paid market research.",
+ "system_prompt": "Call market_research once and return its output verbatim.",
+ "tools": [market_research],
+ }],
+ system_prompt="Delegate research requests to research-sub.",
+)
+```
+
+The buyer side is unchanged — the token goes on the run, and the buyer does not
+need to know the agent's internal topology:
+
+```python
+graph.invoke(
+ {"messages": [{"role": "user", "content": "Research the EV market"}]},
+ config={"configurable": {"payment_token": access_token}},
+)
+```
+
+### Two harness behaviours to design around
+
+**A deep agent can bill several times per user turn.** The supervisor, not you,
+decides how many subagent calls a request warrants, so one user message may
+settle credits more than once. Cap it explicitly rather than trusting the model
+to be frugal — count paid calls per run (key on
+`config["configurable"]["thread_id"]` or `run_id`) and return a plain refusal
+once the cap is hit. Refund the reservation when a call raises
+`PaymentRequiredError`, so a user who authorizes mid-run still gets what they
+paid for.
+
+**Two LLM layers can paraphrase the tool's output.** The subagent relays to the
+supervisor, which relays to the user; neither is guaranteed to pass text through
+verbatim, and a capable supervisor may even answer a paid question from its own
+knowledge instead of delegating — silently giving the capability away. Forbid
+that explicitly in both system prompts, and treat the tool's return value, not
+the chat reply, as the source of truth.
+
+### Version note
+
+`deepagents` requires the LangChain v1 stack (`langchain>=1.3.18`,
+`langchain-core>=1.6.1`). If your project pins an older `langchain-core`, give
+the deep agent its own virtualenv.
+
+Compatibility is pinned by `tests/unit/x402/test_deepagents_compat.py`, which
+drives a real deep agent through a scripted fake model (no network, no LLM) and
+asserts the token reaches a subagent's tool. They run in CI under the dedicated
+`deepagents_compat` job — separate from the main test job, which pins the
+Python 3.10 floor that `deepagents` cannot install on. To reproduce locally:
+
+```bash
+python3.11 -m venv .venv-deepagents
+.venv-deepagents/bin/pip install -e ".[langchain,langsmith]"
+.venv-deepagents/bin/pip install deepagents pytest
+.venv-deepagents/bin/pytest tests/unit/x402/test_deepagents_compat.py
+```
+
## Related
- [LangChain integration guide](/integrate/add-to-your-agent/langchain) — conceptual walk-through, the two integration approaches (decorator vs. HTTP middleware), and the TypeScript variant.
- [x402 Protocol](/api-reference/python/x402-module) — token generation, delegation config, scheme resolution.
- [`tutorials/langchain-paid-agent-py`](https://github.com/nevermined-io/tutorials/tree/main/langchain-paid-agent-py) — the minimal end-to-end demo.
+- [`tutorials/langchain-research-agent-py`](https://github.com/nevermined-io/tutorials/tree/main/langchain-research-agent-py) — freemium in-tool gating on `create_react_agent`.
+- [`tutorials/langchain-deep-agent-py`](https://github.com/nevermined-io/tutorials/tree/main/langchain-deep-agent-py) — the same pattern on the Deep Agents harness, with the paid tool inside a subagent.
diff --git a/api-reference/python/mpp-module.mdx b/api-reference/python/mpp-module.mdx
new file mode 100644
index 00000000..792ebd84
--- /dev/null
+++ b/api-reference/python/mpp-module.mdx
@@ -0,0 +1,330 @@
+---
+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.
+
+`payments.mpp.get_mpp_access_token(plan_id, agent_id, token_options)` mints the
+buyer's credential directly, for a buyer not using `payments.mpp.fetch`. It
+takes an `MppTokenOptions` — the same fields as `X402TokenOptions` **minus the
+whole v3 binding**: `token_version`, `resource` and `http_verb`.
+
+
+x402 and MPP no longer share a version ladder (nvm-monorepo#3266). x402's
+single-use unit is the **token** — a v3 token carries a one-time nonce and
+is consumed by its first settle. MPP's is the **challenge**, whose id is the
+burn idempotency key, and one MPP access token is presented across many
+challenges by design. A per-token nonce would therefore kill every buyer's
+second challenge.
+
+The backend refuses **any** `tokenVersion` on an MPP mint with
+`BCK.MPP.0007` — `2` included, since that ordinal belongs to x402's ladder.
+The SDK refuses it before the request, so you get a `PaymentsError`
+(`code='validation'`) naming the cause rather than an opaque 400. The mint
+response carries no `tokenVersion` key either.
+
+`MppTokenOptions` is a **sibling** of `X402TokenOptions`, not its base — so
+passing an `X402TokenOptions` here now fails type checking. It still runs
+(pydantic does not enforce annotations, and the shared fields are
+identical) and it still raises at the mint if it carries any of the v3
+binding, but the annotation is what rejects it at the call site instead of
+leaving the runtime guard as the only defence. Construct an
+`MppTokenOptions`.
+
+`resource` and `http_verb` are refused too. An MPP token's struct has no
+members for them, so they bind nothing — but redemption runs through the
+same shared erc4337 `verify`, where the presence of the token's
+`resource.url` is what arms the endpoint allowlist. Sending them would
+switch on a check you never configured while adding no binding at all. An
+MPP credential is bound by its **challenge**, not by its token.
+
+
+
+`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/requests-module.mdx b/api-reference/python/requests-module.mdx
index 48cbca49..1b6c0b70 100644
--- a/api-reference/python/requests-module.mdx
+++ b/api-reference/python/requests-module.mdx
@@ -33,6 +33,7 @@ result = payments.x402.get_x402_access_token(
access_token = result['accessToken']
print(f"Access Token: {access_token[:50]}...")
+print(f"Token version: {result.get('tokenVersion')}") # 2 or 3, absent if the mint returned no token
```
### Token Generation Parameters
@@ -41,7 +42,25 @@ print(f"Access Token: {access_token[:50]}...")
|-----------|------|----------|-------------|
| `plan_id` | `str` | Yes | The payment plan ID |
| `agent_id` | `str` | No | Target agent ID (recommended) |
-| `token_options` | `X402TokenOptions` | No | Scheme and delegation configuration |
+| `token_options` | `X402TokenOptions` | No | Scheme, delegation, resource binding and requested token version |
+
+`X402TokenOptions` fields:
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `scheme` | `str` | x402 scheme (defaults to `nvm:erc4337`) |
+| `network` | `str` | Network identifier (auto-derived from the scheme if omitted) |
+| `delegation_config` | `DelegationConfig` | Delegation to mint against |
+| `resource` | `str` \| `X402Resource` | The protected resource the token is minted for. Signed on v3 |
+| `http_verb` | `str` | HTTP verb of that resource (e.g. `"POST"`). Signed on v3 |
+| `token_version` | `int` | Access-token version to **request** (`2` or `3`). Never read it back from here — see below |
+
+The result is a dict with:
+
+| Key | Type | Description |
+|-----|------|-------------|
+| `accessToken` | `str` | The access token to send in `payment-signature` |
+| `tokenVersion` | `int` | `2` or `3`, **detected from the returned token**, not from what was requested |
### Token Generation with Delegation
@@ -80,6 +99,36 @@ result = payments.x402.get_x402_access_token(
> still works but emits a `DeprecationWarning` and will be removed in a future
> release. Create the delegation first as shown above.
+### Single-Use Tokens (v3)
+
+By default the backend mints a **v2** token: a reusable bearer credential you
+can keep for the lifetime of your client. Opt into a **v3** token to get one
+that is bound to a single seller endpoint and is **consumed by its first
+settle**:
+
+```python
+result = payments.x402.get_x402_access_token(
+ plan_id="your-plan-id",
+ agent_id="agent-id",
+ token_options=X402TokenOptions(
+ delegation_config=DelegationConfig(delegation_id=delegation.delegation_id),
+ resource="https://seller.example/api/v1/tasks",
+ http_verb="POST",
+ token_version=3,
+ ),
+)
+
+if result.get("tokenVersion") == 3:
+ ... # do NOT reuse: mint a fresh token for the next paid request
+```
+
+Requesting v3 is not a guarantee of getting v3 — a backend that predates v3
+support drops the field silently and returns v2. Always read
+`result.get("tokenVersion")` (or `is_single_use_access_token(access_token)`), never the
+value you passed in. Full details, including the `BCK.X402.0059`
+"already used" error, are in
+[x402 Payment Protocol](/api-reference/python/x402-module#access-token-versions-v2-and-v3).
+
## Make Requests to Agents
### Using the x402 Payment Header
@@ -134,6 +183,21 @@ if decoded:
print(f"Subscriber: {authorization.get('from')}")
print(f"Plan ID: {authorization.get('planId')}")
print(f"Agent ID: {authorization.get('agentId')}")
+
+ # v3 tokens additionally carry the signed binding and a one-time nonce.
+ print(f"Resource URL: {authorization.get('resourceUrl')}")
+ print(f"HTTP verb: {authorization.get('httpVerb')}")
+ print(f"Nonce: {authorization.get('nonce')}")
+```
+
+The `nonce` is what distinguishes the two versions — but use the helpers rather
+than reading it yourself:
+
+```python
+from payments_py.x402 import detect_access_token_version, is_single_use_access_token
+
+detect_access_token_version(access_token) # 2 or 3
+is_single_use_access_token(access_token) # True / False
```
## Complete Example
diff --git a/api-reference/python/x402-module.mdx b/api-reference/python/x402-module.mdx
index 051879c9..b08c2e5d 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
@@ -246,6 +250,155 @@ The x402 token is a base64-encoded JSON document:
}
```
+### Access Token Versions (v2 and v3)
+
+A **v2** token — what the backend mints by default today — is a *bearer*
+credential. Its EIP-712 signature covers only
+`[from, sessionKeysProvider, sessionKeys, planId]`: `agentId`, `resource.url`
+and `httpVerb` sit outside the signature and there is no nonce. Consequences:
+any seller holding a token minted for plan `P` can present it to another seller
+on the same plan, and the same token can be settled more than once.
+
+A **v3** token additionally signs `agentId`, `resourceUrl`, `httpVerb` and a
+one-time `nonce`. That binds it to one seller and one endpoint, and makes it
+**single-use**: the first `POST /x402/settle` consumes it. `verify()` never
+consumes, so the standard verify-then-settle flow is unchanged and verify stays
+repeatable.
+
+v3 is **opt-in**. Request it with `token_version=3`, and give the token the
+`resource` and `http_verb` it should be bound to:
+
+```python
+from payments_py.x402 import DelegationConfig, X402TokenOptions
+
+result = payments.x402.get_x402_access_token(
+ plan_id,
+ agent_id,
+ token_options=X402TokenOptions(
+ delegation_config=DelegationConfig(delegation_id=delegation_id),
+ resource="https://seller.example/api/v1/tasks",
+ http_verb="POST",
+ token_version=3,
+ ),
+)
+
+access_token = result["accessToken"]
+if result.get("tokenVersion") == 3:
+ ... # single-use: mint a fresh token for the next paid request
+```
+
+`resource` accepts a URL string or an `X402Resource` (which also carries
+`description` / `mime_type`).
+
+
+They are **not** inert on a v2 token, so the SDK refuses them without
+`token_version=3` rather than forwarding or dropping them. On v2 they land
+on the unsigned envelope and bind nothing, but the presence of the token's
+`resource.url` is exactly what switches the backend's endpoint allowlist
+**on** — the `resource.url not provided in token … skipping endpoint
+validation` log is the marker of a check being skipped, not noise to tidy
+away. Adding `resource` to a working v2 flow therefore buys no binding and
+can turn it into `BCK.PROTOCOL.0031`.
+
+
+
+Because v3 requires `resource`, and `resource` arms that allowlist, a v3
+token fails `BCK.PROTOCOL.0031` for any agent this SDK registered with an
+`endpoints` list. `AgentAPIAttributes` serializes each entry as
+`{"verb": …, "url": …}` while the backend reads `{ : }`, so no
+entry can ever match. Until [payments-py#274](https://github.com/nevermined-io/payments-py/issues/274)
+lands, v3 is usable only for agents registered with **no** `endpoints`
+(absent ⇒ allow-all) — so treat v3 as opt-in for that configuration rather
+than as the default path for every agent.
+
+
+
+The two are siblings now, so passing an `X402TokenOptions` to
+`payments.mpp.get_mpp_access_token` fails type checking. It still runs and
+still raises at the mint if it carries any of the v3 binding — the change
+is that the annotation rejects it at the call site rather than leaving the
+runtime guard as the only defence. Construct an `MppTokenOptions` there.
+
+
+
+Both options models are `extra="forbid"`. That strictness is what makes
+`MppTokenOptions(token_version=3)` an error rather than a silently dropped
+field, but `X402TokenOptions` inherits it: a call that previously passed a
+superset dict (`X402TokenOptions(**config)`) now raises `ValidationError`
+instead of ignoring the extra keys. Filter the dict to the declared fields,
+or pass them explicitly.
+
+
+### Which URL do I bind?
+
+The one **the seller advertises** in its 402 `resource.url`. The backend
+compares the two by `origin + path` and falls back to exact string equality
+when either side does not parse as an absolute URL — so a relative `/ask` on
+one side and `https://seller.example/ask` on the other can never match, and the
+settle fails. Sellers built on this SDK's middleware advertise whatever
+`endpoint` they pass to `build_payment_required`, which is commonly the
+request's relative path. Check what your seller sends before binding.
+
+> **Never infer the version from what you asked for.** The backend's
+> `ValidationPipe` runs with `whitelist: true` and *without*
+> `forbidNonWhitelisted`, so `tokenVersion: 3` sent to a deployment that
+> predates v3 support is dropped **without an error** and you get a v2 token
+> back. Read the version off the token you received — that is exactly what the
+> `tokenVersion` key of the response reports:
+
+```python
+from payments_py.x402 import detect_access_token_version, is_single_use_access_token
+
+is_single_use_access_token(access_token) # True / False
+detect_access_token_version(access_token) # 2 or 3
+```
+
+A field absent at mint is signed as the empty string, and the unsigned envelope
+copy must then also be absent. Any post-mint edit of the envelope that
+disagrees with the signed value is rejected as forgery (`BCK.X402.0005`), so
+relay the token **byte-for-byte** — never re-encode, trim or normalise it.
+
+#### Single-use means: mint per paid request
+
+Do not cache a v3 token across paid requests. A second settle of the same token
+fails with **`BCK.X402.0059`**, surfaced by the SDK as its own error type:
+
+```python
+from payments_py.x402 import AccessTokenAlreadyUsedError, is_access_token_already_used
+
+try:
+ settlement = payments.facilitator.settle_permissions(
+ payment_required=payment_required,
+ x402_access_token=access_token,
+ )
+except AccessTokenAlreadyUsedError:
+ # Mint a NEW token — retrying the same one can only fail again.
+ ...
+```
+
+`AccessTokenAlreadyUsedError` subclasses `PaymentsError`, so existing
+`except PaymentsError` handlers keep working; `is_access_token_already_used(err)`
+checks the wire code (`BCK.X402.0059`) rather than the class, which also works
+across a process boundary.
+
+The A2A client follows the same rule automatically: `PaymentsClient` caches a v2
+token for its lifetime but mints a v3 token per paid request. Pass
+`token_version=3` to `payments.a2a["get_client"]` to opt in.
+
+**MPP carries no token version at all.** The two protocols stopped sharing a
+version ladder (nvm-monorepo#3266) because their single-use unit differs: for
+x402 it is the *token* (the v3 nonce), for MPP it is the *challenge*, whose id
+doubles as the burn idempotency key. One MPP access token is presented across
+many challenges by design, so a per-token nonce would kill every buyer's second
+challenge.
+
+`payments.mpp.get_mpp_access_token` therefore takes an `MppTokenOptions` — the
+same fields minus `token_version` — and refuses any version before the request;
+the backend answers `BCK.MPP.0007` for **any** value, `2` included, since that
+ordinal belongs to x402's ladder. Its response carries no `tokenVersion` key
+either: there is no version to report. `payments.mpp.fetch` is unaffected — it
+never asked for one.
+
## Verify Payment Permissions
Verification checks if a subscriber has valid permissions without burning credits:
@@ -513,7 +666,7 @@ sequenceDiagram
4. **Handle 402 responses**: Return proper payment required responses with scheme info
-5. **Cache verifications carefully**: Tokens can be used multiple times until limits are reached
+5. **Cache verifications carefully**: a v2 token can be used multiple times until limits are reached; a **v3 token is single-use** and must be re-minted per paid request (see [Access Token Versions](#access-token-versions-v2-and-v3))
## Error Codes
@@ -524,6 +677,11 @@ sequenceDiagram
| `insufficient_balance` | Not enough credits | Order more credits |
| `invalid_plan` | Plan ID mismatch | Use correct plan ID |
| `invalid_agent` | Agent ID mismatch | Use correct agent ID |
+| `BCK.X402.0005` | Envelope disagrees with the signed value | Relay the token byte-for-byte; do not edit it |
+| `BCK.X402.0059` | v3 access token already used (spent by its first settle) | Mint a new token — raised as `AccessTokenAlreadyUsedError` |
+| `BCK.MPP.0007` | A `tokenVersion` was sent to the MPP mint | MPP has no version ladder — omit the field (the SDK refuses it client-side) |
+| `BCK.X402.0013` | The token's `resource.url` does not match the seller's `paymentRequired.resource.url` | Bind the exact string the seller advertises — see [Which URL do I bind?](#which-url-do-i-bind) |
+| `BCK.PROTOCOL.0031` | The bound endpoint is not in the agent's `endpoints` allowlist | Register the agent without `endpoints`, or wait for payments-py#274 |
## Next Steps