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