x402 Protocol¶
Looking for MPP? The Machine Payments Protocol is a second framing over this same plan/credits/delegation core — see 15. MPP Protocol.
This guide covers the x402 payment protocol for verifying permissions and settling payments.
Overview¶
x402 is a payment protocol that enables:
- Permission Generation: Subscribers create access tokens for agents
- Permission Verification: Agents verify tokens without burning credits
- Permission Settlement: Agents burn credits after completing work
The protocol is named after HTTP status code 402 (Payment Required).
Supported Schemes¶
Nevermined supports two x402 payment schemes:
| Scheme | Network | Use Case | Settlement |
|---|---|---|---|
nvm:erc4337 |
eip155:84532 |
Crypto payments | ERC-4337 UserOps + session keys |
nvm:card-delegation |
stripe | braintree | visa |
Fiat/credit card | Provider charge + credit burn |
The scheme is determined by the plan's pricing configuration. Plans with isCrypto: false use nvm:card-delegation; all others use nvm:erc4337. The SDK auto-detects the scheme via resolve_scheme(). The network value within nvm:card-delegation is determined by which provider issued the delegation being consumed (stripe, braintree, or visa).
Visa support¶
Visa delegations use the same nvm:card-delegation scheme and SDK surface as Stripe and Braintree, but two steps must happen in a browser before the SDK can consume them:
- Card enrolment — the cardholder enrols a Visa card through VGS Collect (PCI-compliant iframe) in the Nevermined webapp. The card is bound to a Visa Agentic Token via the VGS Credential Management Platform.
- Delegation creation — the cardholder approves a delegation via a WebAuthn/passkey (FIDO) device-binding ceremony embedded by Visa VTS. This produces a single-use
assuranceDatablob bound to the spending limit + duration + merchant context.
Both steps require a real DOM and a user gesture, so the SDK cannot perform them programmatically. Once a Visa delegation exists, the SDK consumes it identically to Stripe/Braintree — pass delegation_id to DelegationConfig and call get_x402_access_token as usual:
from payments_py.x402 import X402TokenOptions, DelegationConfig
result = subscriber_payments.x402.get_x402_access_token(
plan_id,
token_options=X402TokenOptions(
scheme="nvm:card-delegation",
network="visa",
delegation_config=DelegationConfig(
delegation_id="11111111-1111-1111-1111-111111111111",
),
),
)
delegation_id reuse is the only supported pattern for Visa — create_delegation(provider="visa", ...) is rejected by the backend without the browser-only consumer_prompt + assurance_data blobs the SDK has no way to produce. When a Visa creation call fails this way, PaymentsError.code carries the backend BCK.VISA.0014 so consumers can branch programmatically.
Generate Payment Permissions¶
From Nevermined App¶
The easiest way to generate permissions is through the Nevermined App Permissions page:
- Navigate to the permissions page
- Select your plan and agent
- Configure limits (optional)
- Generate the access token
From SDK¶
from payments_py import Payments, PaymentOptions
payments = Payments.get_instance(
PaymentOptions(nvm_api_key="nvm:subscriber-key", environment="sandbox")
)
# Basic token generation
result = payments.x402.get_x402_access_token(
plan_id="your-plan-id",
agent_id="agent-id"
)
access_token = result['accessToken']
# With delegation config (crypto — erc4337)
from payments_py.x402 import X402TokenOptions, DelegationConfig, CreateDelegationPayload
# Create the delegation once (currency is required), then reuse it across plans.
delegation = payments.delegation.create_delegation(
CreateDelegationPayload(
provider="erc4337",
spending_limit_cents=10000, # $100
duration_secs=604800, # 1 week
currency="usdc",
)
)
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)
)
)
Deprecated: passing
spending_limit_cents/duration_secsdirectly toget_x402_access_token(inline create-on-the-fly, aDelegationConfigwith nodelegation_id) emits aDeprecationWarningand will be removed in a future release. Create the delegation first as shown above, then pass onlydelegation_id.
Card-Delegation Token Generation¶
For fiat plans using nvm:card-delegation, create the card delegation once
(currency is required), then request the token by delegation_id:
from payments_py.x402 import X402TokenOptions, DelegationConfig, CreateDelegationPayload
# Step 1 — create a USD card delegation (currency is required).
usd_delegation = payments.delegation.create_delegation(
CreateDelegationPayload(
provider="stripe",
provider_payment_method_id="pm_1AbCdEfGhIjKlM",
spending_limit_cents=10000, # $100.00
duration_secs=2592000, # 30 days
currency="usd",
max_transactions=100,
)
)
# Step 2 — request the token referencing the delegation id.
result = payments.x402.get_x402_access_token(
plan_id="your-plan-id",
agent_id="agent-id",
token_options=X402TokenOptions(
scheme="nvm:card-delegation",
delegation_config=DelegationConfig(
delegation_id=usd_delegation.delegation_id
),
)
)
access_token = result['accessToken']
# EUR card delegation — same flow, currency="eur".
eur_delegation = payments.delegation.create_delegation(
CreateDelegationPayload(
provider="stripe",
provider_payment_method_id="pm_1AbCdEfGhIjKlM",
spending_limit_cents=10000, # €100.00 (in euro cents)
duration_secs=2592000, # 30 days
currency="eur",
max_transactions=100,
)
)
eur_result = payments.x402.get_x402_access_token(
plan_id="your-plan-id",
agent_id="agent-id",
token_options=X402TokenOptions(
scheme="nvm:card-delegation",
delegation_config=DelegationConfig(
delegation_id=eur_delegation.delegation_id
),
)
)
Auto Scheme Resolution¶
Use resolve_scheme() to auto-detect the correct scheme from plan metadata:
from payments_py.x402.resolve_scheme import resolve_scheme
# Auto-detect scheme from plan metadata (cached for 5 minutes)
scheme = resolve_scheme(payments, plan_id="your-plan-id")
# Returns "nvm:erc4337" for crypto plans, "nvm:card-delegation" for fiat plans
# Explicit override
scheme = resolve_scheme(payments, plan_id="your-plan-id", explicit_scheme="nvm:card-delegation")
DelegationAPI¶
Create delegations and list enrolled payment methods:
from payments_py.x402 import CreateDelegationPayload
# Create a crypto delegation (currency is required)
delegation = payments.delegation.create_delegation(
CreateDelegationPayload(
provider="erc4337",
spending_limit_cents=10000,
duration_secs=604800,
currency="usdc",
)
)
print(f"Delegation ID: {delegation.delegation_id}")
# List enrolled payment methods (every provider)
methods = payments.delegation.list_payment_methods()
for method in methods:
print(f"{method.brand} ****{method.last4} (expires {method.exp_month}/{method.exp_year})")
# e.g., "visa ****4242 (expires 12/2027)"
# Restrict the result to a single provider with the optional `provider` kwarg
stripe_methods = payments.delegation.list_payment_methods(provider="stripe")
list_payment_methods() accepts an optional provider keyword argument
('stripe' | 'braintree' | 'visa' | 'erc4337'). When set, it is forwarded as a
?provider= query string and only methods backed by that provider are returned.
Omit it (the default) to return methods from every provider.
PaymentMethodSummary fields:
| Field | Type | Description |
|---|---|---|
id |
str |
Payment method ID (e.g., pm_...) |
brand |
str |
Card brand (e.g., visa, mastercard) |
last4 |
str |
Last 4 digits of the card number |
exp_month |
int |
Card expiration month |
exp_year |
int |
Card expiration year |
Token Structure¶
The x402 token is a base64-encoded JSON document:
{
"payload": {
"authorization": {
"from": "0xSubscriberAddress",
"planId": "plan-123",
"agentId": "agent-456"
},
"sessionKey": {
"address": "0xSessionKeyAddress",
"permissions": ["order", "burn"],
"limits": {
"redemptionLimit": 100,
"orderLimit": "1000000000000000000"
}
}
},
"signature": "0x..."
}
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:
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).
resource and http_verb are v3-only
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.
v3 does not work yet for agents registered with endpoints
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 { <VERB>: <url> }, so no
entry can ever match. Until payments-py#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.
Breaking: X402TokenOptions is no longer an MppTokenOptions
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.
Breaking: X402TokenOptions now rejects unknown keyword arguments
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
ValidationPiperuns withwhitelist: trueand withoutforbidNonWhitelisted, sotokenVersion: 3sent 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 thetokenVersionkey of the response reports:
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:
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:
from payments_py import Payments, PaymentOptions
from payments_py.x402.helpers import build_payment_required
payments = Payments.get_instance(
PaymentOptions(nvm_api_key="nvm:agent-key", environment="sandbox")
)
# Build the 402 Payment Required specification
payment_required = build_payment_required(
plan_id="your-plan-id",
endpoint="https://your-api.com/endpoint",
agent_id="your-agent-id",
http_verb="POST"
)
# Verify the token
verification = payments.facilitator.verify_permissions(
payment_required=payment_required,
x402_access_token=access_token,
max_amount="1" # Optional: max credits to verify
)
if verification.is_valid:
print(f"Valid! Payer: {verification.payer}")
else:
print(f"Invalid: {verification.invalid_reason}")
Verification Response¶
| Field | Type | Description |
|---|---|---|
is_valid |
bool |
Whether verification passed |
invalid_reason |
str |
Reason for invalidity (if is_valid is false) |
payer |
str |
Payer's wallet address |
agent_request_id |
str |
Agent request ID for observability tracking |
Settle Payment Permissions¶
Settlement burns credits after successfully processing a request:
# After processing the request successfully
settlement = payments.facilitator.settle_permissions(
payment_required=payment_required,
x402_access_token=access_token,
max_amount="1", # Credits to burn
agent_request_id="request-123" # Optional: for tracking
)
if settlement.success:
print(f"Transaction: {settlement.transaction}")
# Read `billing_model` before the credit fields — see "Was the buyer charged?" below
if settlement.billing_model == "pay-as-you-go":
print(f"Charged, reference: {settlement.order_tx or settlement.transaction}")
else:
print(f"Settled! Credits burned: {settlement.credits_redeemed}")
print(f"Remaining: {settlement.remaining_balance}")
else:
print(f"Settlement failed: {settlement.error_reason}")
Settlement Response¶
| Field | Type | Description |
|---|---|---|
success |
bool |
Whether settlement succeeded |
error_reason |
str |
Reason for failure (if success is false) |
payer |
str |
Payer's wallet address |
transaction |
str |
Blockchain transaction hash. Also the charge reference on crypto pay-as-you-go plans |
network |
str |
The rail: a CAIP-2 chain id for crypto, or the settling PSP (stripe, braintree, visa) for fiat |
billing_model |
str |
How the request was priced: credits or pay-as-you-go. Read this before the two credit fields |
credits_redeemed |
str |
Credits that were burned. Always "0" on pay-as-you-go, including on a successful charge |
remaining_balance |
str |
Credits remaining. Always "0" on pay-as-you-go |
order_tx |
str |
Order / per-request charge reference. On fiat pay-as-you-go this is the PSP transaction id |
Was the buyer charged?¶
settlement.success tells you the settle worked. What to check in addition depends on
settlement.billing_model:
billing_model |
Success criterion | Credit fields |
|---|---|---|
credits |
success and int(credits_redeemed) > 0 |
credits_redeemed is the amount burned, remaining_balance what is left |
pay-as-you-go |
success and a non-empty order_tx (fiat rails) / transaction (crypto rails) |
always the string "0" — no balance exists on this plan shape |
No billing_model in the response? You are talking to a Nevermined API that predates the
discriminator. Apply the credits rule — never read a missing discriminator as pay-as-you-go.
settled = settlement.success and (
bool(settlement.order_tx or settlement.transaction)
if settlement.billing_model == "pay-as-you-go"
else int(settlement.credits_redeemed or "0") > 0
)
Two things make this easy to get wrong:
- Do not gate on
credits_redeemedwithout readingbilling_modelfirst. A pay-as-you-go plan holds no credit balance, socredits_redeemed > 0can never hold there — a real charge reads as a decline. On a card rail that invites a retry of a payment that already succeeded, and repeated attempts feed issuer fraud scoring. - These fields are strings.
"0"is truthy whileint("0") > 0is false, so two plausible-looking checks disagree. Compare numerically, and only oncreditsplans.
Payment Required Object¶
The X402PaymentRequired object specifies what payment is required. The scheme and network fields vary by payment type:
from payments_py.x402.types import X402PaymentRequired, X402Resource, X402Scheme, X402SchemeExtra
# Crypto plan (nvm:erc4337)
payment_required = X402PaymentRequired(
x402_version=2,
resource=X402Resource(
url="https://your-api.com/endpoint",
description="Protected endpoint" # Optional
),
accepts=[
X402Scheme(
scheme="nvm:erc4337",
network="eip155:84532", # Base Sepolia
plan_id="your-plan-id",
extra=X402SchemeExtra(
http_verb="POST", # HTTP method goes in extra, not resource
agent_id="agent-123" # Optional
)
)
],
extensions={}
)
# Fiat plan (nvm:card-delegation)
payment_required_fiat = X402PaymentRequired(
x402_version=2,
resource=X402Resource(
url="https://your-api.com/endpoint",
description="Protected endpoint"
),
accepts=[
X402Scheme(
scheme="nvm:card-delegation",
network="stripe",
plan_id="your-plan-id",
extra=X402SchemeExtra(
http_verb="POST",
agent_id="agent-123"
)
)
],
extensions={}
)
Using the Helpers¶
from payments_py.x402.helpers import build_payment_required, build_payment_required_for_plans
# Single plan (scheme auto-detected from plan metadata when omitted)
payment_required = build_payment_required(
plan_id="your-plan-id",
endpoint="https://api.example.com/tasks",
agent_id="agent-123",
http_verb="POST"
)
# Explicit scheme override
payment_required = build_payment_required(
plan_id="your-plan-id",
endpoint="https://api.example.com/tasks",
agent_id="agent-123",
http_verb="POST",
scheme="nvm:card-delegation" # Force fiat/Stripe scheme
)
# Multiple plans — creates one entry per plan in accepts[]
payment_required = build_payment_required_for_plans(
plan_ids=["plan-basic", "plan-premium"],
endpoint="https://api.example.com/tasks",
agent_id="agent-123",
http_verb="POST"
)
For a single plan, build_payment_required_for_plans delegates to build_payment_required internally. When scheme is omitted, the network defaults to eip155:84532 (Base Sepolia). When scheme="nvm:card-delegation", the network is automatically set to stripe.
Complete Workflow Example¶
from payments_py import Payments, PaymentOptions
from payments_py.x402.helpers import build_payment_required
from flask import Flask, request, jsonify
app = Flask(__name__)
# Agent's payments instance
agent_payments = Payments.get_instance(
PaymentOptions(nvm_api_key="nvm:agent-key", environment="sandbox")
)
PLAN_ID = "your-plan-id"
AGENT_ID = "your-agent-id"
@app.route('/api/process', methods=['POST'])
def process_request():
# 1. Extract x402 token from payment-signature header
token = request.headers.get('payment-signature', '')
if not token:
return jsonify({'error': 'Missing payment-signature header'}), 402
# 2. Build payment requirement
payment_required = build_payment_required(
plan_id=PLAN_ID,
endpoint=request.url,
agent_id=AGENT_ID,
http_verb=request.method
)
# 3. Verify (doesn't burn credits)
verification = agent_payments.facilitator.verify_permissions(
payment_required=payment_required,
x402_access_token=token,
max_amount="1"
)
if not verification.is_valid:
return jsonify({
'error': 'Payment required',
'details': verification.invalid_reason,
'paymentRequired': payment_required.model_dump()
}), 402
# 4. Process the request
try:
result = do_expensive_work(request.json)
except Exception as e:
# Don't settle on failure
return jsonify({'error': str(e)}), 500
# 5. Settle (burn credits) on success
settlement = agent_payments.facilitator.settle_permissions(
payment_required=payment_required,
x402_access_token=token,
max_amount="1"
)
return jsonify({
'result': result,
'billingModel': settlement.billing_model,
'creditsUsed': settlement.credits_redeemed,
'remainingBalance': settlement.remaining_balance,
'orderTx': settlement.order_tx,
})
def do_expensive_work(data):
# Your processing logic
return {'processed': True}
if __name__ == '__main__':
app.run(port=8080)
HTTP Flow¶
sequenceDiagram
participant Subscriber
participant Agent
participant Nevermined
Subscriber->>Agent: GET /api/process (no token)
Agent-->>Subscriber: 402 Payment Required<br/>{paymentRequired: {...}}
Subscriber->>Nevermined: get_x402_access_token()
Nevermined-->>Subscriber: {accessToken: "..."}
Subscriber->>Agent: GET /api/process<br/>payment-signature: token
Agent->>Nevermined: verify_permissions()
Nevermined-->>Agent: {isValid: true}
Note over Agent: Process request
Agent->>Nevermined: settle_permissions()
Nevermined-->>Agent: {success: true}
Agent-->>Subscriber: 200 OK {result: ...}
Best Practices¶
-
Always verify before processing: Don't do expensive work without verification
-
Only settle on success: Don't burn credits if processing fails
-
Use agent_request_id: Include request IDs for tracking and debugging
-
Handle 402 responses: Return proper payment required responses with scheme info
-
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)
-
Branch on
billing_model: Never decide "was the buyer charged?" fromcredits_redeemedalone — see Was the buyer charged? above
Error Codes¶
| Error | Description | Resolution |
|---|---|---|
invalid_token |
Token is malformed | Generate a new token |
expired_token |
Token has expired | Generate a new token |
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? |
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¶
- Request Validation - More validation patterns
- MCP Integration - x402 with MCP servers
- OAuth 401 vs. payment-required - In-band x402 v2 MCP signaling