From 8913e092140f74708b577d5a90f46948e19436a6 Mon Sep 17 00:00:00 2001 From: Rodolphe Marques Date: Tue, 8 Sep 2026 15:30:18 +0200 Subject: [PATCH 1/5] docs(x402): state the settle success criterion per billing model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a pay-as-you-go plan a fully successful `POST /api/v1/x402/settle` returns `creditsRedeemed: "0"` and `remainingBalance: "0"`. Those plans hold no credit balance — each settle is one direct charge — and the charge is referenced by `orderTx` (fiat rails) or `transaction` (crypto rails). Several pages here published the opposite advice. The payments skill's `SKILL.md` said "Proof of purchase = `success: true` with `creditsRedeemed` > 0 and a `remainingBalance`", and `references/autonomous-operations.md` and `references/client-integration.md` said the same. That check is unreachable on a pay-as-you-go plan, so it reports a real charge as a decline — and on a card rail that invites a retry of a payment that already went through, which is the one thing we tell integrators never to do. Every affected page now states the criterion per billing model, using the discriminator the API has always returned: - `credits` — `success === true` and `creditsRedeemed > 0`. - `pay-as-you-go` — `success === true` plus a non-empty `orderTx` (fiat) / `transaction` (crypto); the credit fields are always `"0"` and carry no information. - The values are strings, so `"0"` is truthy while `Number("0") > 0` is false. Changed: the payments skill + three of its references, the five IDE rule files that list the settle response fields, both x402 specs (receipt tables plus a new "Was the buyer charged?" section), `products/x402-facilitator/how-it-works.mdx`, `agents-guide/check-credits.mdx`, `solutions/api-providers.mdx`, `integrate/patterns/charge-credits.mdx` and `development-guide/nevermined-x402.mdx`. `api-reference/openapi.json` is hand-maintained, and its `SettlePermissionResponse` schema did not match the endpoint at all — it named `permissionHash` / `creditsSettled` / `remainingCredits` / `txHash`, three of which the API does not return, and typed the credit fields as integers. It now describes the real response, including `billingModel` and the criterion. This is the published, interactive API reference and clients are generated from it. `integrations/youdotcom.mdx` already carried the correct guidance and needed no change. No API shape changed; `billingModel` was already on the wire. Refs nevermined-io/nvm-monorepo#2999 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CVpimo1dBXnTJo9tQSy8SA --- .clinerules/nevermined-payments.md | 2 +- .cursor/rules/nevermined-payments.mdc | 2 +- .github/copilot-instructions.md | 2 +- .windsurf/rules/nevermined-payments.md | 2 +- AGENTS.md | 2 +- agents-guide/check-credits.mdx | 2 +- api-reference/openapi.json | 48 +++++++++++++++---- development-guide/nevermined-x402.mdx | 14 +++++- integrate/patterns/charge-credits.mdx | 10 ++++ products/x402-facilitator/how-it-works.mdx | 15 ++++++ skills/nevermined-payments/SKILL.md | 9 ++-- .../references/autonomous-operations.md | 22 ++++++++- .../references/client-integration.md | 10 +++- .../references/mcp-paywall.md | 4 +- solutions/api-providers.mdx | 8 +++- specs/x402-card-delegation.mdx | 43 +++++++++++++++-- specs/x402-smart-accounts.mdx | 20 +++++++- 17 files changed, 181 insertions(+), 34 deletions(-) diff --git a/.clinerules/nevermined-payments.md b/.clinerules/nevermined-payments.md index 0a8b9a4f..986ef121 100644 --- a/.clinerules/nevermined-payments.md +++ b/.clinerules/nevermined-payments.md @@ -107,7 +107,7 @@ await payments.a2a.start({ port: 3005, basePath: '/a2a/', agentCard, executor }) ## Autonomous Operations (REST, no SDK) -When an agent must act on its own behalf at runtime (buy a plan, enroll a card, check credits/revenue), call the REST API directly with `Authorization: Bearer $NVM_API_KEY` against `https://api.sandbox.nevermined.app` (sandbox) or `https://api.live.nevermined.app` (live). Buy in two calls — `POST /api/v1/x402/permissions` (→ `accessToken`) then `POST /api/v1/x402/settle` (→ `creditsRedeemed`, `remainingBalance`). Crypto uses `scheme: "nvm:erc4337"` / `network: "eip155:84532"`; cards use `scheme: "nvm:card-delegation"` / `network: "stripe"`. A human is needed only for one-time setup — the first API key, plus card enrollment if paying by card (the stablecoin path needs neither). Full runbook: `skills/nevermined-payments/references/autonomous-operations.md`. +When an agent must act on its own behalf at runtime (buy a plan, enroll a card, check credits/revenue), call the REST API directly with `Authorization: Bearer $NVM_API_KEY` against `https://api.sandbox.nevermined.app` (sandbox) or `https://api.live.nevermined.app` (live). Buy in two calls — `POST /api/v1/x402/permissions` (→ `accessToken`) then `POST /api/v1/x402/settle` (→ `billingModel`, `creditsRedeemed`, `remainingBalance`, `orderTx`; on `pay-as-you-go` the two credit fields are always `"0"` even on a successful charge, so the proof is `success` + a non-empty `orderTx`/`transaction`). Crypto uses `scheme: "nvm:erc4337"` / `network: "eip155:84532"`; cards use `scheme: "nvm:card-delegation"` / `network: "stripe"`. A human is needed only for one-time setup — the first API key, plus card enrollment if paying by card (the stablecoin path needs neither). Full runbook: `skills/nevermined-payments/references/autonomous-operations.md`. ## Full Skill Reference diff --git a/.cursor/rules/nevermined-payments.mdc b/.cursor/rules/nevermined-payments.mdc index b57d986a..57cbe5ad 100644 --- a/.cursor/rules/nevermined-payments.mdc +++ b/.cursor/rules/nevermined-payments.mdc @@ -104,7 +104,7 @@ await payments.a2a.start({ port: 3005, basePath: '/a2a/', agentCard, executor }) ## Autonomous Operations (REST, no SDK) -When an agent must act on its own behalf at runtime (buy a plan, enroll a card, check credits/revenue), call the REST API directly with `Authorization: Bearer $NVM_API_KEY` against `https://api.sandbox.nevermined.app` (sandbox) or `https://api.live.nevermined.app` (live). Buy in two calls — `POST /api/v1/x402/permissions` (→ `accessToken`) then `POST /api/v1/x402/settle` (→ `creditsRedeemed`, `remainingBalance`). Crypto uses `scheme: "nvm:erc4337"` / `network: "eip155:84532"`; cards use `scheme: "nvm:card-delegation"` / `network: "stripe"`. A human is needed only for one-time setup — minting the first API key, plus card enrollment if paying by card (the stablecoin path needs neither afterward). Full runbook: `skills/nevermined-payments/references/autonomous-operations.md`. +When an agent must act on its own behalf at runtime (buy a plan, enroll a card, check credits/revenue), call the REST API directly with `Authorization: Bearer $NVM_API_KEY` against `https://api.sandbox.nevermined.app` (sandbox) or `https://api.live.nevermined.app` (live). Buy in two calls — `POST /api/v1/x402/permissions` (→ `accessToken`) then `POST /api/v1/x402/settle` (→ `billingModel`, `creditsRedeemed`, `remainingBalance`, `orderTx`; on `pay-as-you-go` the two credit fields are always `"0"` even on a successful charge, so the proof is `success` + a non-empty `orderTx`/`transaction`). Crypto uses `scheme: "nvm:erc4337"` / `network: "eip155:84532"`; cards use `scheme: "nvm:card-delegation"` / `network: "stripe"`. A human is needed only for one-time setup — minting the first API key, plus card enrollment if paying by card (the stablecoin path needs neither afterward). Full runbook: `skills/nevermined-payments/references/autonomous-operations.md`. - Pin the API version on direct REST calls: `Nevermined-Version: ` (discover via `GET /api/v1/meta/versions`; SDKs send it automatically). Never silently change an API key's stored version pin. - Delegation first: create a spending delegation (`POST /delegation/create` with `provider` + `currency` + `spendingLimitCents` + `durationSecs`, or SDK `createDelegation`) and reuse its `delegationId`. The inline create-on-the-fly `delegationConfig` (limits without a `delegationId`) is **deprecated since `@nevermined-io/payments` 1.8.0** — it only emits a runtime warning; always pass an explicit `delegationId`. - Seller analytics: discover your `orgId` from `.orgId` on `GET /protocol/plans` / `/protocol/agents` records. A malformed/placeholder `orgId` returns a silent 200-of-zeros; non-Premium → `403 BCK.ORGANIZATIONS.0022`; non-admin → `403 BCK.AUTH.0004`. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 6aa1e4e7..05e02ed7 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -87,7 +87,7 @@ When an agent must act on its own behalf at runtime (buy a plan, enroll a card, # 1. token POST /api/v1/x402/permissions { "accepted": { "scheme": "nvm:erc4337", "network": "eip155:84532", "planId": "" }, "delegationConfig": { "delegationId": "" } } → { accessToken } # 2. settle (proof of purchase) -POST /api/v1/x402/settle { "paymentRequired": { "x402Version": 2, "resource": { "url": "" }, "accepts": [ { "scheme": "nvm:erc4337", "network": "eip155:84532", "planId": "", "extra": {} } ], "extensions": {} }, "x402AccessToken": "" } → { creditsRedeemed, remainingBalance } +POST /api/v1/x402/settle { "paymentRequired": { "x402Version": 2, "resource": { "url": "" }, "accepts": [ { "scheme": "nvm:erc4337", "network": "eip155:84532", "planId": "", "extra": {} } ], "extensions": {} }, "x402AccessToken": "" } → { billingModel, creditsRedeemed, remainingBalance, orderTx } # billingModel is "credits" | "pay-as-you-go"; on pay-as-you-go both credit fields are always "0" even on a successful charge — the proof is success + a non-empty orderTx/transaction ``` Card payments: `scheme: "nvm:card-delegation"`, `network: "stripe"`. A human is needed only for one-time setup — minting the first API key, plus card enrollment if paying by card (the stablecoin path needs neither afterward). Full runbook: `skills/nevermined-payments/references/autonomous-operations.md`. diff --git a/.windsurf/rules/nevermined-payments.md b/.windsurf/rules/nevermined-payments.md index 147e09e4..8a4ab586 100644 --- a/.windsurf/rules/nevermined-payments.md +++ b/.windsurf/rules/nevermined-payments.md @@ -99,7 +99,7 @@ await payments.a2a.start({ port: 3005, basePath: '/a2a/', agentCard, executor }) ## Autonomous Operations (REST, no SDK) -When an agent must act on its own behalf at runtime (buy a plan, enroll a card, check credits/revenue), call the REST API directly with `Authorization: Bearer $NVM_API_KEY` against `https://api.sandbox.nevermined.app` (sandbox) or `https://api.live.nevermined.app` (live). Buy in two calls — `POST /api/v1/x402/permissions` (→ `accessToken`) then `POST /api/v1/x402/settle` (→ `creditsRedeemed`, `remainingBalance`). Crypto uses `scheme: "nvm:erc4337"` / `network: "eip155:84532"`; cards use `scheme: "nvm:card-delegation"` / `network: "stripe"`. A human is needed only for one-time setup — the first API key, plus card enrollment if paying by card (the stablecoin path needs neither). Full runbook: `skills/nevermined-payments/references/autonomous-operations.md`. +When an agent must act on its own behalf at runtime (buy a plan, enroll a card, check credits/revenue), call the REST API directly with `Authorization: Bearer $NVM_API_KEY` against `https://api.sandbox.nevermined.app` (sandbox) or `https://api.live.nevermined.app` (live). Buy in two calls — `POST /api/v1/x402/permissions` (→ `accessToken`) then `POST /api/v1/x402/settle` (→ `billingModel`, `creditsRedeemed`, `remainingBalance`, `orderTx`; on `pay-as-you-go` the two credit fields are always `"0"` even on a successful charge, so the proof is `success` + a non-empty `orderTx`/`transaction`). Crypto uses `scheme: "nvm:erc4337"` / `network: "eip155:84532"`; cards use `scheme: "nvm:card-delegation"` / `network: "stripe"`. A human is needed only for one-time setup — the first API key, plus card enrollment if paying by card (the stablecoin path needs neither). Full runbook: `skills/nevermined-payments/references/autonomous-operations.md`. ## Full Skill Reference diff --git a/AGENTS.md b/AGENTS.md index a0436805..ae955c18 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -80,7 +80,7 @@ await payments.a2a.start({ port: 3005, basePath: '/a2a/', agentCard, executor }) ## Autonomous Operations (REST, no SDK) -When an agent must act on its own behalf at runtime (buy a plan, enroll a card, check credits/revenue), call the REST API directly with `Authorization: Bearer $NVM_API_KEY` against `https://api.sandbox.nevermined.app` (sandbox) or `https://api.live.nevermined.app` (live). Buy in two calls — `POST /api/v1/x402/permissions` (→ `accessToken`) then `POST /api/v1/x402/settle` (→ `creditsRedeemed`, `remainingBalance`). Crypto uses `scheme: "nvm:erc4337"` / `network: "eip155:84532"`; cards use `scheme: "nvm:card-delegation"` / `network: "stripe"`. A human is needed only for one-time setup — the first API key, plus card enrollment if paying by card (the stablecoin path needs neither). Full runbook: `skills/nevermined-payments/references/autonomous-operations.md`. +When an agent must act on its own behalf at runtime (buy a plan, enroll a card, check credits/revenue), call the REST API directly with `Authorization: Bearer $NVM_API_KEY` against `https://api.sandbox.nevermined.app` (sandbox) or `https://api.live.nevermined.app` (live). Buy in two calls — `POST /api/v1/x402/permissions` (→ `accessToken`) then `POST /api/v1/x402/settle` (→ `billingModel`, `creditsRedeemed`, `remainingBalance`, `orderTx`; on `pay-as-you-go` the two credit fields are always `"0"` even on a successful charge, so the proof is `success` + a non-empty `orderTx`/`transaction`). Crypto uses `scheme: "nvm:erc4337"` / `network: "eip155:84532"`; cards use `scheme: "nvm:card-delegation"` / `network: "stripe"`. A human is needed only for one-time setup — the first API key, plus card enrollment if paying by card (the stablecoin path needs neither). Full runbook: `skills/nevermined-payments/references/autonomous-operations.md`. ## Nevermined Router — paying external services {/* Keep this Router section in sync with the identical one in .github/copilot-instructions.md (and vice-versa). MDX comment syntax is deliberate — Mintlify parses this file and rejects HTML comments. */} diff --git a/agents-guide/check-credits.mdx b/agents-guide/check-credits.mdx index e0ca3359..390013d9 100644 --- a/agents-guide/check-credits.mdx +++ b/agents-guide/check-credits.mdx @@ -83,7 +83,7 @@ All three checks are simple authenticated `GET`s. - The receipt from a purchase (`creditsRedeemed` / `remainingBalance`, returned by `settle` or decoded from the `payment-response` header) is also a live proof of your balance right after a buy. + The receipt from a purchase (`creditsRedeemed` / `remainingBalance`, returned by `settle` or decoded from the `payment-response` header) is also a live proof of your balance right after a buy — **on a `credits` plan**. A pay-as-you-go plan holds no credit balance: its receipt reads `"0"` for both fields whatever was charged, and the charge is referenced by `orderTx` (fiat rails) or `transaction` (crypto rails). Read `billingModel` off the receipt before either field. diff --git a/api-reference/openapi.json b/api-reference/openapi.json index 8f00a036..a017c8d9 100644 --- a/api-reference/openapi.json +++ b/api-reference/openapi.json @@ -3032,21 +3032,51 @@ }, "SettlePermissionResponse": { "type": "object", + "description": "Settlement receipt. `success` alone tells you the settle worked; what to check in addition depends on `billingModel`. On `credits` plans the payment shows up as `creditsRedeemed > 0`. On `pay-as-you-go` plans there is no credit balance, so `creditsRedeemed` and `remainingBalance` are always the string \"0\" even on a charge that succeeded — the proof there is `success: true` plus a non-empty `orderTx` (fiat rails) or `transaction` (crypto rails). Do not gate on `creditsRedeemed` without reading `billingModel` first: on a card rail that reports a real charge as a decline and invites a retry of a payment that already went through. Note the credit fields are strings, so \"0\" is truthy while Number(\"0\") > 0 is false.", "properties": { "success": { - "type": "boolean" + "type": "boolean", + "description": "Whether settlement was successful." }, - "permissionHash": { - "type": "string" + "errorReason": { + "type": "string", + "description": "Reason for settlement failure (only present if success is false)." }, - "creditsSettled": { - "type": "integer" + "payer": { + "type": "string", + "description": "Address of the payer's wallet." }, - "remainingCredits": { - "type": "integer" + "transaction": { + "type": "string", + "description": "Blockchain transaction hash. On crypto pay-as-you-go plans this is also the reference for the per-request charge." }, - "txHash": { - "type": "string" + "network": { + "type": "string", + "description": "Network identifier. The discriminator is the rail, not the billing model: a CAIP-2 chain id (eip155:84532) for crypto rails under both billing models, or the settling payment provider (stripe, braintree, visa) for fiat card-delegation rails.", + "example": "eip155:84532" + }, + "billingModel": { + "type": "string", + "enum": [ + "credits", + "pay-as-you-go" + ], + "description": "Which billing model this settle was priced under. Present regardless of `success` — check `success` before treating it as evidence of a charge. Read it before the two credit fields." + }, + "creditsRedeemed": { + "type": "string", + "description": "Number of credits redeemed. Always \"0\" for pay-as-you-go plans, which hold no credit balance — including on a settle that charged the buyer successfully.", + "example": "2" + }, + "remainingBalance": { + "type": "string", + "description": "Subscriber's remaining credit balance. Always \"0\" for pay-as-you-go plans — the per-request charge is referenced by `orderTx` (fiat) or `transaction` (crypto), not here.", + "example": "98" + }, + "orderTx": { + "type": "string", + "description": "Reference for the order or per-request charge, if one occurred. On fiat pay-as-you-go this is the per-request charge (a Stripe PaymentIntent id, a Braintree transaction id); crypto pay-as-you-go reports its on-chain order in `transaction` instead. On credits plans it is set only when the settle had to order credits first (auto top-up).", + "example": "pi_3TUrvfBYvSRKcV420xCBjHb1" } } }, diff --git a/development-guide/nevermined-x402.mdx b/development-guide/nevermined-x402.mdx index 905cdc3c..4a590d75 100644 --- a/development-guide/nevermined-x402.mdx +++ b/development-guide/nevermined-x402.mdx @@ -572,10 +572,15 @@ For other frameworks or custom implementations, follow these steps: maxAmount: BigInt(actualCreditsUsed), }) - // Return response with settlement receipt in payment-response header + // Return response with settlement receipt in payment-response header. + // Pass `billingModel` through: on a pay-as-you-go plan the credit fields are + // always "0", so without it the buyer cannot tell a real charge from a + // credits settle that burned nothing. const settlementReceipt = Buffer.from(JSON.stringify({ success: true, + billingModel: settlement.billingModel, creditsRedeemed: actualCreditsUsed, + orderTx: settlement.orderTx, transactionHash: settlement.txHash, })).toString('base64') @@ -594,10 +599,15 @@ For other frameworks or custom implementations, follow these steps: max_amount=actual_credits_used, ) - # Return response with settlement receipt in payment-response header + # Return response with settlement receipt in payment-response header. + # Pass `billingModel` through: on a pay-as-you-go plan the credit fields are + # always "0", so without it the buyer cannot tell a real charge from a + # credits settle that burned nothing. settlement_receipt = base64.b64encode(json.dumps({ "success": True, + "billingModel": settlement.billing_model, "creditsRedeemed": actual_credits_used, + "orderTx": settlement.order_tx, "transactionHash": settlement.tx_hash, }).encode()).decode() diff --git a/integrate/patterns/charge-credits.mdx b/integrate/patterns/charge-credits.mdx index 9b0ea562..e9599091 100644 --- a/integrate/patterns/charge-credits.mdx +++ b/integrate/patterns/charge-credits.mdx @@ -6,6 +6,16 @@ icon: "coins" Copy-paste patterns for deducting credits from subscribers. + + **These patterns assume a `credits` plan.** A pay-as-you-go plan holds no credit balance — each + request is charged directly — so a successful settle there returns `creditsRedeemed: "0"` and + `remainingBalance: "0"`, with the charge referenced by `orderTx` (fiat rails) or `transaction` + (crypto rails). The settle response always carries `billingModel` (`credits` | `pay-as-you-go`); + read it before either credit field. Gating on `creditsRedeemed > 0` alone reports a real + pay-as-you-go charge as a decline, and on a card rail that invites a retry of a payment that + already succeeded. + + ## Automatic Credit Deduction With the x402 flow, verification checks permissions without burning credits. After processing, settlement burns the credits: diff --git a/products/x402-facilitator/how-it-works.mdx b/products/x402-facilitator/how-it-works.mdx index fa33d067..8d6b670e 100644 --- a/products/x402-facilitator/how-it-works.mdx +++ b/products/x402-facilitator/how-it-works.mdx @@ -216,6 +216,8 @@ Send the x402 access token in the `payment-signature` header: const settlement = JSON.parse( Buffer.from(result.headers.get(X402_HEADERS.PAYMENT_RESPONSE)!, 'base64').toString() ) + // `billingModel` says how to read this receipt — see the note below + console.log('Billing model:', settlement.billingModel) console.log('Credits used:', settlement.creditsRedeemed) } ``` @@ -236,11 +238,24 @@ Send the x402 access token in the `payment-signature` header: settlement = json.loads( base64.b64decode(result.headers.get('payment-response')).decode() ) + # `billingModel` says how to read this receipt — see the note below + print('Billing model:', settlement.get('billingModel')) print('Credits used:', settlement['creditsRedeemed']) ``` + + **A `creditsRedeemed` of `"0"` does not mean the call was free.** The settle receipt carries + `billingModel`: on a `credits` plan the payment shows up as `creditsRedeemed > 0`, but on a + **pay-as-you-go** plan there is no credit balance at all, so both `creditsRedeemed` and + `remainingBalance` are always the string `"0"` — even on a charge that succeeded. There the + proof is `success: true` plus a non-empty `orderTx` (fiat rails) or `transaction` (crypto + rails). Gating on `creditsRedeemed > 0` alone reports a real charge as a decline, and on a card + rail that invites a retry of a payment that already went through. Note the values are + **strings**: `"0"` is truthy while `Number("0") > 0` is false. + + ## Resource server flow (API/agent side) diff --git a/skills/nevermined-payments/SKILL.md b/skills/nevermined-payments/SKILL.md index d7262fa6..9d64bca7 100644 --- a/skills/nevermined-payments/SKILL.md +++ b/skills/nevermined-payments/SKILL.md @@ -195,7 +195,7 @@ curl -X POST -H "Authorization: Bearer $NVM_API_KEY" -H "Content-Type: applicati "x402AccessToken": "" }' \ https://api.sandbox.nevermined.app/api/v1/x402/settle -# → { "success": true, "creditsRedeemed": "1", "remainingBalance": "999", "transaction": "0x...", "network": "eip155:84532" } +# → { "success": true, "billingModel": "credits", "creditsRedeemed": "1", "remainingBalance": "999", "transaction": "0x...", "network": "eip155:84532" } ``` - **Pay with a card instead:** set `"scheme": "nvm:card-delegation"` and `"network": "stripe"` (or `braintree`/`visa`) in both `accepted` and `accepts[0]`. @@ -203,7 +203,10 @@ curl -X POST -H "Authorization: Bearer $NVM_API_KEY" -H "Content-Type: applicati - **Which scheme does a plan use?** `GET {API_BASE}/api/v1/protocol/plans/` (public) returns the plan's metadata and pricing so you can pick `nvm:erc4337` vs `nvm:card-delegation` before paying. When buying from a protected agent, its `402` tells you instead. - **Note the field rename:** `/permissions` returns `accessToken`; pass that value as `x402AccessToken` in `/settle` and `/verify`. - **Dry run first (optional):** `POST /api/v1/x402/verify` with the same `{ paymentRequired, x402AccessToken }` body → `{ "isValid": true }`. -- **Proof of purchase** = `success: true` with `creditsRedeemed` > 0 and a `remainingBalance` (and, for crypto, an on-chain `transaction`). +- **Proof of purchase depends on `billingModel`** — read it first, it is always in the response. + - `"credits"`: `success: true` **and** `creditsRedeemed > 0` (and, for crypto, an on-chain `transaction`). + - `"pay-as-you-go"`: `success: true` **and** a non-empty `orderTx` (fiat rails) or `transaction` (crypto rails). These plans hold no credit balance, so `creditsRedeemed` and `remainingBalance` are **always the string `"0"` even on a charge that succeeded** — `creditsRedeemed > 0` there reports a real charge as a decline, and on a card rail that invites a retry of a payment that already went through. + - Both fields are **strings**: `"0"` is truthy while `Number("0") > 0` is false, so two plausible checks disagree. - **Card budget caveat:** a card settle may not immediately move the delegation's `amountSpentCents`/`remainingBudgetCents` — use the settle receipt + the A5 plan balance as the source of truth for card spend, not the delegation budget. **Calling a protected agent directly** (the common case): just send the access token as the `payment-signature` header to the agent's endpoint — the agent's own `402` response **is** your `paymentRequired`, and the agent verifies + settles for you. You only call `/settle` yourself when topping up a plan with no protected endpoint to hit. @@ -246,7 +249,7 @@ curl -H "Authorization: Bearer $NVM_API_KEY" \ https://api.sandbox.nevermined.app/api/v1/delegation//transactions ``` -SDK: `payments.plans.getPlanBalance(planId)` (`PlanBalance.balance` is a `bigint` in TS / `int` in Python). The `creditsRedeemed`/`remainingBalance` you get back from `/settle` (or the decoded `payment-response` header) is also a live proof of your balance after a purchase. +SDK: `payments.plans.getPlanBalance(planId)` (`PlanBalance.balance` is a `bigint` in TS / `int` in Python). The `creditsRedeemed`/`remainingBalance` you get back from `/settle` (or the decoded `payment-response` header) is also a live proof of your balance after a purchase — **on a `credits` plan**. On a `pay-as-you-go` plan both read `"0"` regardless of what was charged; there is no balance to prove. ## A6 · Register a plan + agent (as a seller) *(fully programmatic — SDK-first)* diff --git a/skills/nevermined-payments/references/autonomous-operations.md b/skills/nevermined-payments/references/autonomous-operations.md index e51e7b7a..fb751ac7 100644 --- a/skills/nevermined-payments/references/autonomous-operations.md +++ b/skills/nevermined-payments/references/autonomous-operations.md @@ -190,12 +190,32 @@ Response (`X402SettleResponseDto`): "payer": "0xabc...", "transaction": "0xdef...", "network": "eip155:84532", + "billingModel": "credits", "creditsRedeemed": "1", "remainingBalance": "999" } ``` -`success: true` with `creditsRedeemed > 0` and a `remainingBalance` is your proof. For a plan top-up with no protected endpoint, set `resource.url` to the plan's own URL — `{API_BASE}/api/v1/protocol/plans/`. +**Your proof of payment depends on `billingModel`**, which is always present — read it before the credit fields: + +- `"credits"` — `success: true` **and** `creditsRedeemed > 0`, with `remainingBalance` as the balance left. +- `"pay-as-you-go"` — `success: true` **and** a non-empty `orderTx` (fiat rails) or `transaction` (crypto rails). These plans hold no credit balance, so a successful charge still returns `creditsRedeemed: "0"` and `remainingBalance: "0"`: + +```json +{ + "success": true, + "transaction": "pi_3U6tgrBYvSRKcV421ehH4bnX", + "network": "stripe", + "billingModel": "pay-as-you-go", + "creditsRedeemed": "0", + "remainingBalance": "0", + "orderTx": "pi_3U6tgrBYvSRKcV421ehH4bnX" +} +``` + +Checking `creditsRedeemed > 0` on such a plan reports a real charge as a decline — and on a card rail that invites a retry of a payment that already went through. Both fields are **strings**, so `"0"` is truthy while `Number("0") > 0` is false. + +For a plan top-up with no protected endpoint, set `resource.url` to the plan's own URL — `{API_BASE}/api/v1/protocol/plans/`. ### 4c. Dry-run (optional) diff --git a/skills/nevermined-payments/references/client-integration.md b/skills/nevermined-payments/references/client-integration.md index b7be8d62..865f9661 100644 --- a/skills/nevermined-payments/references/client-integration.md +++ b/skills/nevermined-payments/references/client-integration.md @@ -32,12 +32,12 @@ curl -s -X POST -H "Authorization: Bearer $NVM_API_KEY" -H "Content-Type: applic "extensions": {} }, "x402AccessToken": "" }' \ https://api.sandbox.nevermined.app/api/v1/x402/settle -# → { "success": true, "creditsRedeemed": "1", "remainingBalance": "999", "transaction": "0x..." } +# → { "success": true, "billingModel": "credits", "creditsRedeemed": "1", "remainingBalance": "999", "transaction": "0x..." } ``` - **Card payment:** switch `scheme` to `nvm:card-delegation` and `network` to `stripe` (or `braintree`/`visa`) in both calls. - **Calling a protected agent directly:** skip building `paymentRequired` — send the access token as the `payment-signature` header; the agent settles for you and returns the receipt in the `payment-response` header. -- **Proof of purchase:** `creditsRedeemed > 0` and a `remainingBalance` in the settle response. +- **Proof of purchase — read `billingModel` first.** On a `credits` plan it is `success: true` and `creditsRedeemed > 0`. On a `pay-as-you-go` plan there is no credit balance, so `creditsRedeemed` and `remainingBalance` are always the string `"0"` even on a successful charge; the proof is `success: true` plus a non-empty `orderTx` (fiat) or `transaction` (crypto). Never gate on `creditsRedeemed` alone — on a card rail it reports a real charge as a decline and invites a retry. Full runbook with API-key retrieval, card enrollment, and status checks: `autonomous-operations.md`. @@ -180,6 +180,9 @@ async function callProtectedAPI() { 'base64' ).toString() ) + // On a pay-as-you-go plan `creditsRedeemed` is always '0' — see "Proof of + // purchase" above; the charge reference is `orderTx` / `transaction`. + console.log('Billing model:', settlement.billingModel) console.log('Credits used:', settlement.creditsRedeemed) } } @@ -256,6 +259,9 @@ def call_protected_api(): response2.headers.get("payment-response") ).decode() ) + # On a pay-as-you-go plan creditsRedeemed is always "0" — see "Proof + # of purchase" above; the charge reference is orderTx / transaction. + print(f"Billing model: {settlement.get('billingModel')}") print(f"Credits used: {settlement.get('creditsRedeemed')}") if __name__ == "__main__": diff --git a/skills/nevermined-payments/references/mcp-paywall.md b/skills/nevermined-payments/references/mcp-paywall.md index cbc2e5e8..54955fdd 100644 --- a/skills/nevermined-payments/references/mcp-paywall.md +++ b/skills/nevermined-payments/references/mcp-paywall.md @@ -143,8 +143,8 @@ After each paywall-protected call, the SDK injects a `_meta` field into the resp |-------|------|-------------| | `success` | `boolean` | Whether credit redemption succeeded | | `txHash` | `string` | Blockchain transaction hash (only on success) | -| `creditsRedeemed` | `string` | Number of credits burned (`'0'` on failure) | -| `remainingBalance` | `string` | Credits remaining after redemption | +| `creditsRedeemed` | `string` | Number of credits burned (`'0'` on failure — **and always `'0'` on a pay-as-you-go plan, including a successful charge**) | +| `remainingBalance` | `string` | Credits remaining after redemption (also always `'0'` on pay-as-you-go) | | `planId` | `string` | Plan used for the operation | | `subscriberAddress` | `string` | Subscriber's wallet address | | `errorReason` | `string` | Error message (only on failure) | diff --git a/solutions/api-providers.mdx b/solutions/api-providers.mdx index 7a907f2c..cace8a2f 100644 --- a/solutions/api-providers.mdx +++ b/solutions/api-providers.mdx @@ -195,8 +195,12 @@ Your handler does three things: read the `payment-signature` header, verify the Response: `{ "isValid": true, ... }` or `{ "isValid": false, "invalidReason": "..." }`. **Settle** uses the same body shape posted to `/x402/settle`. Response - contains `creditsRedeemed`, `remainingBalance`, `transaction`, and - (when auto top-up fired) `orderTx`. + contains `billingModel`, `creditsRedeemed`, `remainingBalance`, + `transaction`, and `orderTx`. Read `billingModel` before the credit + fields: on a `pay-as-you-go` plan both read `"0"` even on a charge that + succeeded, and the charge is referenced by `orderTx` (fiat) or + `transaction` (crypto). On a `credits` plan `orderTx` is present only when + auto top-up fired. **Your response headers** (per x402 spec §4.3): diff --git a/specs/x402-card-delegation.mdx b/specs/x402-card-delegation.mdx index 5be4b2c6..c6b653e6 100644 --- a/specs/x402-card-delegation.mdx +++ b/specs/x402-card-delegation.mdx @@ -662,13 +662,14 @@ PAYMENT-RESPONSE: eyJzdWNjZXNzIjp0cnVlLCJwYXltZW50SW50ZW50SWQiOi4uLg== } ``` -**Decoded PAYMENT-RESPONSE:** +**Decoded PAYMENT-RESPONSE** (a `credits` plan): ```json { "success": true, "transaction": "0x1234567890abcdef...", "network": "stripe", + "billingModel": "credits", "creditsRedeemed": "2", "remainingBalance": "98", "orderTx": "pi_1AbCdEfGhIjKlM" @@ -677,6 +678,22 @@ PAYMENT-RESPONSE: eyJzdWNjZXNzIjp0cnVlLCJwYXltZW50SW50ZW50SWQiOi4uLg== The `PAYMENT-RESPONSE` header contains x402-standard settlement fields. The `network` echoes the value used at verification (`stripe` or `braintree`). For Braintree, `orderTx` is the Braintree merchant transaction ID. Additional Nevermined-specific info (like `creditsRedeemed` and `remainingBalance`) can be included in the response body. +The same successful settle on a **pay-as-you-go** plan reports `"0"` for both credit fields, because such a plan holds no credit balance — the charge is referenced by `orderTx`: + +```json +{ + "success": true, + "transaction": "pi_3U6tgrBYvSRKcV421ehH4bnX", + "network": "stripe", + "billingModel": "pay-as-you-go", + "creditsRedeemed": "0", + "remainingBalance": "0", + "orderTx": "pi_3U6tgrBYvSRKcV421ehH4bnX" +} +``` + +See [Was the buyer charged?](#66-was-the-buyer-charged) for the check that applies to each. + Steps 27-28 represent a failure scenario where the server has already performed work but settlement failed. Implementations SHOULD have mechanisms to handle this edge case, such as retry logic or dispute resolution. @@ -796,10 +813,26 @@ Additional settlement details MAY be included in the response body: | Field | Type | Description | |-------|------|-------------| -| `creditsRedeemed` | `string` | Number of credits burned on-chain. | -| `remainingBalance` | `string` | Subscriber's remaining credit balance. | -| `orderTx` | `string` | PSP PaymentIntent ID if auto top-up occurred (only present when card was charged). | -| `transaction` | `string` | Blockchain transaction hash of the credit burn. | +| `billingModel` | `string` | How the request was priced: `credits` or `pay-as-you-go`. Read this before the two credit fields. | +| `creditsRedeemed` | `string` | Number of credits burned on-chain. Always `"0"` on `pay-as-you-go`, including on a successful charge. | +| `remainingBalance` | `string` | Subscriber's remaining credit balance. Always `"0"` on `pay-as-you-go`. | +| `orderTx` | `string` | PSP transaction ID. On `credits` plans, present only when auto top-up charged the card; on fiat `pay-as-you-go`, it references the per-request charge. | +| `transaction` | `string` | Blockchain transaction hash of the credit burn. On crypto `pay-as-you-go` it is the charge reference instead. | + +### 6.6 Was the buyer charged? + +`success` alone tells you the settle worked. What a consumer MUST check **in addition** depends on `billingModel`: + +| `billingModel` | Success criterion | Credit fields | +|----------------|-------------------|---------------| +| `credits` | `success === true` and `creditsRedeemed > 0` | `creditsRedeemed` is the amount burned, `remainingBalance` what is left | +| `pay-as-you-go` | `success === true` **and** a non-empty `orderTx` (fiat rails) / `transaction` (crypto rails) | always the string `"0"` — no balance exists on this plan shape | + + +Do not gate on `creditsRedeemed` without reading `billingModel` first. A pay-as-you-go plan holds no credit balance, so `creditsRedeemed > 0` can never hold there and 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. + +Note the type as well: both fields are **strings**, so `"0"` is truthy while `Number("0") > 0` is false — two plausible-looking checks disagree. + ## 7. Error Handling diff --git a/specs/x402-smart-accounts.mdx b/specs/x402-smart-accounts.mdx index b38161a2..cbad2945 100644 --- a/specs/x402-smart-accounts.mdx +++ b/specs/x402-smart-accounts.mdx @@ -620,10 +620,26 @@ Additional settlement details MAY be included in the response body: | Field | Type | Description | |-------|------|-------------| -| `creditsRedeemed` | `string` | Number of credits redeemed. | -| `remainingBalance` | `string` | Subscriber's remaining balance. | +| `billingModel` | `string` | How the request was priced: `credits` or `pay-as-you-go`. Read this before the two credit fields. | +| `creditsRedeemed` | `string` | Number of credits redeemed. Always `"0"` on `pay-as-you-go`, including on a settle that charged the buyer. | +| `remainingBalance` | `string` | Subscriber's remaining balance. Always `"0"` on `pay-as-you-go`. | | `orderTx` | `string` | Transaction hash of the order operation (if executed). | +### 6.4 Was the buyer charged? + +`success` alone tells you the settle worked. What a consumer MUST check **in addition** depends on `billingModel`: + +| `billingModel` | Success criterion | Credit fields | +|----------------|-------------------|---------------| +| `credits` | `success === true` and `creditsRedeemed > 0` | `creditsRedeemed` is the amount burned, `remainingBalance` what is left | +| `pay-as-you-go` | `success === true` **and** a non-empty `transaction` (the on-chain order) | always the string `"0"` — no balance exists on this plan shape | + + +Do not gate on `creditsRedeemed` without reading `billingModel` first. A pay-as-you-go plan holds no credit balance, so `creditsRedeemed > 0` can never hold there and a real payment reads as a decline. + +Note the type as well: both fields are **strings**, so `"0"` is truthy while `Number("0") > 0` is false — two plausible-looking checks disagree. + + ## 7. Error Handling ### 7.1 Error Codes From ec7959dbdde893ba8ac4625fb6abdea7f76d2aea Mon Sep 17 00:00:00 2001 From: Rodolphe Marques Date: Tue, 8 Sep 2026 15:38:42 +0200 Subject: [PATCH 2/5] =?UTF-8?q?fix(docs):=20the=20x402=20settle=20result?= =?UTF-8?q?=20has=20no=20txHash=20field=20=E2=80=94=20it=20is=20transactio?= =?UTF-8?q?n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drive-by, inside the receipt-construction block the previous commit already edited to pass `billingModel` through. The example read `settlement.txHash` (TS) / `settlement.tx_hash` (Python), a field that exists on neither SDK's settle result: both return `transaction`, alongside `success`, `errorReason`, `payer`, `network`, `creditsRedeemed`, `remainingBalance` and `orderTx`. Copied as written it puts `undefined` / raises `AttributeError` into the receipt the agent hands its buyer. Verified against `origin/main` in both SDK repos, not against this branch. The same mistake appears in four other places that are not part of this change — `products/x402-facilitator/how-it-works.mdx` (two code blocks), `integrate/add-to-your-agent/express.mdx` and `skills/nevermined-payments/references/express-integration.md` — and is left for a separate PR rather than widened into here. Refs nevermined-io/nvm-monorepo#2999 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CVpimo1dBXnTJo9tQSy8SA --- development-guide/nevermined-x402.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/development-guide/nevermined-x402.mdx b/development-guide/nevermined-x402.mdx index 4a590d75..04a3f51e 100644 --- a/development-guide/nevermined-x402.mdx +++ b/development-guide/nevermined-x402.mdx @@ -581,7 +581,7 @@ For other frameworks or custom implementations, follow these steps: billingModel: settlement.billingModel, creditsRedeemed: actualCreditsUsed, orderTx: settlement.orderTx, - transactionHash: settlement.txHash, + transactionHash: settlement.transaction, })).toString('base64') res.set('payment-response', settlementReceipt) @@ -608,7 +608,7 @@ For other frameworks or custom implementations, follow these steps: "billingModel": settlement.billing_model, "creditsRedeemed": actual_credits_used, "orderTx": settlement.order_tx, - "transactionHash": settlement.tx_hash, + "transactionHash": settlement.transaction, }).encode()).decode() response.headers["payment-response"] = settlement_receipt From eedf0d2bbfd0f6a5642d8237bc7c98ce650757c6 Mon Sep 17 00:00:00 2001 From: Rodolphe Marques Date: Tue, 8 Sep 2026 15:51:03 +0200 Subject: [PATCH 3/5] =?UTF-8?q?fix(docs):=20the=20remaining=20four=20settl?= =?UTF-8?q?ement.txHash=20sites=20=E2=80=94=20the=20field=20is=20transacti?= =?UTF-8?q?on?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the previous commit, which fixed only the two occurrences inside the block it was already editing. Counting them showed six in total, all the same verified error, so the rest are folded in rather than split into a second review round. - products/x402-facilitator/how-it-works.mdx (TS + Python receipt construction) - integrate/add-to-your-agent/express.mdx (onAfterSettle hook) - skills/nevermined-payments/references/express-integration.md (same hook) The two Express ones are `onAfterSettle` callbacks, so the object is whatever the middleware passes rather than a direct settle return — checked rather than assumed. Both rails are safe: the x402 branch passes the resolved value of `settlePermissions`, i.e. a `SettlePermissionsResult`, and the MPP branch passes `MppSettleResult`, which extends it. `transaction` exists on both; `txHash` appears nowhere in the SDK's x402 or MPP surface, so the examples logged `undefined`. The express skill reference is why this could not wait: it ships to AI coding assistants over the same four routes as the skill itself, so leaving it kept handing agent builders a field that exists on neither SDK. As before, only the VALUE changes — `transactionHash` stays as the integrator's own key name in the receipt they build, since renaming that would change what their buyers parse. Refs nevermined-io/nvm-monorepo#2999 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CVpimo1dBXnTJo9tQSy8SA --- integrate/add-to-your-agent/express.mdx | 2 +- products/x402-facilitator/how-it-works.mdx | 4 ++-- skills/nevermined-payments/references/express-integration.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/integrate/add-to-your-agent/express.mdx b/integrate/add-to-your-agent/express.mdx index f38018b4..4b1028f6 100644 --- a/integrate/add-to-your-agent/express.mdx +++ b/integrate/add-to-your-agent/express.mdx @@ -169,7 +169,7 @@ paymentMiddleware(payments, routes, { // Hook after settlement onAfterSettle: (req, creditsUsed, settlement) => { - console.log(`Settled ${creditsUsed} credits, tx: ${settlement.txHash}`) + console.log(`Settled ${creditsUsed} credits, tx: ${settlement.transaction}`) }, // Custom error handler diff --git a/products/x402-facilitator/how-it-works.mdx b/products/x402-facilitator/how-it-works.mdx index 8d6b670e..b274d276 100644 --- a/products/x402-facilitator/how-it-works.mdx +++ b/products/x402-facilitator/how-it-works.mdx @@ -396,7 +396,7 @@ Settle after work is complete and return the settlement receipt in the `payment- const settlementReceipt = { success: true, creditsRedeemed: actualCreditsUsed, - transactionHash: settlement.txHash + transactionHash: settlement.transaction } res.set(X402_HEADERS.PAYMENT_RESPONSE, Buffer.from(JSON.stringify(settlementReceipt)).toString('base64')) @@ -416,7 +416,7 @@ Settle after work is complete and return the settlement receipt in the `payment- settlement_receipt = { 'success': True, 'creditsRedeemed': actual_credits_used, - 'transactionHash': settlement.tx_hash + 'transactionHash': settlement.transaction } response = jsonify({'result': result}) diff --git a/skills/nevermined-payments/references/express-integration.md b/skills/nevermined-payments/references/express-integration.md index 3ba4a319..87918a37 100644 --- a/skills/nevermined-payments/references/express-integration.md +++ b/skills/nevermined-payments/references/express-integration.md @@ -115,7 +115,7 @@ paymentMiddleware(payments, routes, { }, onAfterSettle: (req, creditsUsed, settlement) => { - console.log(`Settled ${creditsUsed} credits, tx: ${settlement.txHash}`) + console.log(`Settled ${creditsUsed} credits, tx: ${settlement.transaction}`) }, onPaymentError: (error, req, res) => { From 684360b5095e05551e44e7789db196e1d1ae757c Mon Sep 17 00:00:00 2001 From: Rodolphe Marques Date: Tue, 8 Sep 2026 18:12:51 +0200 Subject: [PATCH 4/5] docs(x402): name the absent-billingModel case in the success criterion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review nit on nevermined-io/payments#432. The criterion said what to do for `credits` and for `pay-as-you-go`, and the field doc claimed `billingModel` is "present regardless of `success`" — true of a current Nevermined API, false of one that predates the discriminator, where it does not arrive at all. All three fields are optional on the type precisely because of that case, and it was nowhere stated. The criterion now carries the third branch: an absent discriminator means an older deployment, apply the `credits` rule, and never read a missing `billingModel` as pay-as-you-go. It also names what the reviewer's ask did not cover — if `creditsRedeemed` is absent as well, the credits rule has nothing to evaluate, so `success` alone is the answer. Without that clause the documented snippet returns false for a settle that succeeded, which is the same decline-on-success bug this work exists to remove. The "present regardless of success" line is rephrased so both hold: reported whether or not the settle succeeded, and absent entirely on an older deployment. Wording is identical across both SDKs and the docs site. Refs nevermined-io/nvm-monorepo#2999 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CVpimo1dBXnTJo9tQSy8SA --- skills/nevermined-payments/SKILL.md | 1 + .../references/autonomous-operations.md | 2 ++ .../nevermined-payments/references/client-integration.md | 2 +- specs/x402-card-delegation.mdx | 7 +++++++ specs/x402-smart-accounts.mdx | 7 +++++++ 5 files changed, 18 insertions(+), 1 deletion(-) diff --git a/skills/nevermined-payments/SKILL.md b/skills/nevermined-payments/SKILL.md index 9d64bca7..a8e57cb9 100644 --- a/skills/nevermined-payments/SKILL.md +++ b/skills/nevermined-payments/SKILL.md @@ -207,6 +207,7 @@ curl -X POST -H "Authorization: Bearer $NVM_API_KEY" -H "Content-Type: applicati - `"credits"`: `success: true` **and** `creditsRedeemed > 0` (and, for crypto, an on-chain `transaction`). - `"pay-as-you-go"`: `success: true` **and** a non-empty `orderTx` (fiat rails) or `transaction` (crypto rails). These plans hold no credit balance, so `creditsRedeemed` and `remainingBalance` are **always the string `"0"` even on a charge that succeeded** — `creditsRedeemed > 0` there reports a real charge as a decline, and on a card rail that invites a retry of a payment that already went through. - Both fields are **strings**: `"0"` is truthy while `Number("0") > 0` is false, so two plausible checks disagree. + - **No `billingModel` at all?** The deployment predates the discriminator — apply the `credits` rule, never pay-as-you-go. If `creditsRedeemed` is missing too, `success: true` is the whole answer. - **Card budget caveat:** a card settle may not immediately move the delegation's `amountSpentCents`/`remainingBudgetCents` — use the settle receipt + the A5 plan balance as the source of truth for card spend, not the delegation budget. **Calling a protected agent directly** (the common case): just send the access token as the `payment-signature` header to the agent's endpoint — the agent's own `402` response **is** your `paymentRequired`, and the agent verifies + settles for you. You only call `/settle` yourself when topping up a plan with no protected endpoint to hit. diff --git a/skills/nevermined-payments/references/autonomous-operations.md b/skills/nevermined-payments/references/autonomous-operations.md index fb751ac7..4121263f 100644 --- a/skills/nevermined-payments/references/autonomous-operations.md +++ b/skills/nevermined-payments/references/autonomous-operations.md @@ -215,6 +215,8 @@ Response (`X402SettleResponseDto`): Checking `creditsRedeemed > 0` on such a plan reports a real charge as a decline — and on a card rail that invites a retry of a payment that already went through. Both fields are **strings**, so `"0"` is truthy while `Number("0") > 0` is false. +If the response carries **no `billingModel` at all**, the deployment predates the discriminator: apply the `credits` rule, and never read a missing discriminator as pay-as-you-go. All three fields are optional — if `creditsRedeemed` is absent too there is no balance to check, and `success: true` is the whole answer. + For a plan top-up with no protected endpoint, set `resource.url` to the plan's own URL — `{API_BASE}/api/v1/protocol/plans/`. ### 4c. Dry-run (optional) diff --git a/skills/nevermined-payments/references/client-integration.md b/skills/nevermined-payments/references/client-integration.md index 865f9661..30bc00ee 100644 --- a/skills/nevermined-payments/references/client-integration.md +++ b/skills/nevermined-payments/references/client-integration.md @@ -37,7 +37,7 @@ curl -s -X POST -H "Authorization: Bearer $NVM_API_KEY" -H "Content-Type: applic - **Card payment:** switch `scheme` to `nvm:card-delegation` and `network` to `stripe` (or `braintree`/`visa`) in both calls. - **Calling a protected agent directly:** skip building `paymentRequired` — send the access token as the `payment-signature` header; the agent settles for you and returns the receipt in the `payment-response` header. -- **Proof of purchase — read `billingModel` first.** On a `credits` plan it is `success: true` and `creditsRedeemed > 0`. On a `pay-as-you-go` plan there is no credit balance, so `creditsRedeemed` and `remainingBalance` are always the string `"0"` even on a successful charge; the proof is `success: true` plus a non-empty `orderTx` (fiat) or `transaction` (crypto). Never gate on `creditsRedeemed` alone — on a card rail it reports a real charge as a decline and invites a retry. +- **Proof of purchase — read `billingModel` first.** On a `credits` plan it is `success: true` and `creditsRedeemed > 0`. On a `pay-as-you-go` plan there is no credit balance, so `creditsRedeemed` and `remainingBalance` are always the string `"0"` even on a successful charge; the proof is `success: true` plus a non-empty `orderTx` (fiat) or `transaction` (crypto). Never gate on `creditsRedeemed` alone — on a card rail it reports a real charge as a decline and invites a retry. If `billingModel` is missing entirely the deployment predates it: apply the `credits` rule, and if `creditsRedeemed` is missing too, `success: true` is the whole answer. Full runbook with API-key retrieval, card enrollment, and status checks: `autonomous-operations.md`. diff --git a/specs/x402-card-delegation.mdx b/specs/x402-card-delegation.mdx index c6b653e6..da797baf 100644 --- a/specs/x402-card-delegation.mdx +++ b/specs/x402-card-delegation.mdx @@ -828,6 +828,13 @@ Additional settlement details MAY be included in the response body: | `credits` | `success === true` and `creditsRedeemed > 0` | `creditsRedeemed` is the amount burned, `remainingBalance` what is left | | `pay-as-you-go` | `success === true` **and** a non-empty `orderTx` (fiat rails) / `transaction` (crypto rails) | always the string `"0"` — no balance exists on this plan shape | + + **No `billingModel` in the response?** The deployment predates the discriminator. Apply the + **credits** rule — a missing discriminator MUST NOT be read as pay-as-you-go. All three fields + are optional; if `creditsRedeemed` is absent too there is no balance information to check, and + `success === true` is the whole answer. + + Do not gate on `creditsRedeemed` without reading `billingModel` first. A pay-as-you-go plan holds no credit balance, so `creditsRedeemed > 0` can never hold there and 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. diff --git a/specs/x402-smart-accounts.mdx b/specs/x402-smart-accounts.mdx index cbad2945..c85d69ca 100644 --- a/specs/x402-smart-accounts.mdx +++ b/specs/x402-smart-accounts.mdx @@ -634,6 +634,13 @@ Additional settlement details MAY be included in the response body: | `credits` | `success === true` and `creditsRedeemed > 0` | `creditsRedeemed` is the amount burned, `remainingBalance` what is left | | `pay-as-you-go` | `success === true` **and** a non-empty `transaction` (the on-chain order) | always the string `"0"` — no balance exists on this plan shape | + + **No `billingModel` in the response?** The deployment predates the discriminator. Apply the + **credits** rule — a missing discriminator MUST NOT be read as pay-as-you-go. All three fields + are optional; if `creditsRedeemed` is absent too there is no balance information to check, and + `success === true` is the whole answer. + + Do not gate on `creditsRedeemed` without reading `billingModel` first. A pay-as-you-go plan holds no credit balance, so `creditsRedeemed > 0` can never hold there and a real payment reads as a decline. From e827cbe8a7c4ec0d84a1419a85b597aa33aff51f Mon Sep 17 00:00:00 2001 From: Rodolphe Marques Date: Tue, 8 Sep 2026 18:15:46 +0200 Subject: [PATCH 5/5] docs(x402): drop the unreachable branch from the absent-billingModel footnote MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The footnote's tail described a deployment that has not existed since March. Two dates from `git log -S` on nvm-monorepo's `card-delegation-scheme.handler.ts`: `creditsRedeemed` has been in the settle response since 2026-03-25 (3744bc388), `billingModel` only since 2026-08-06 (66ed3614f) — four and a half months apart. So a deployment old enough to omit the discriminator still returns the credit fields, and the "if `creditsRedeemed` is absent too" clause can never fire. An unreachable conditional is worse than no conditional in a doc whose whole job is telling a reader which check to run: it is the part they stop and think about. The valuable half is unchanged and stays everywhere — an absent `billingModel` means an older deployment, apply the `credits` rule, and never read a missing discriminator as pay-as-you-go. Refs nevermined-io/nvm-monorepo#2999 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CVpimo1dBXnTJo9tQSy8SA --- skills/nevermined-payments/SKILL.md | 2 +- .../nevermined-payments/references/autonomous-operations.md | 2 +- skills/nevermined-payments/references/client-integration.md | 2 +- specs/x402-card-delegation.mdx | 4 +--- specs/x402-smart-accounts.mdx | 4 +--- 5 files changed, 5 insertions(+), 9 deletions(-) diff --git a/skills/nevermined-payments/SKILL.md b/skills/nevermined-payments/SKILL.md index a8e57cb9..8d531175 100644 --- a/skills/nevermined-payments/SKILL.md +++ b/skills/nevermined-payments/SKILL.md @@ -207,7 +207,7 @@ curl -X POST -H "Authorization: Bearer $NVM_API_KEY" -H "Content-Type: applicati - `"credits"`: `success: true` **and** `creditsRedeemed > 0` (and, for crypto, an on-chain `transaction`). - `"pay-as-you-go"`: `success: true` **and** a non-empty `orderTx` (fiat rails) or `transaction` (crypto rails). These plans hold no credit balance, so `creditsRedeemed` and `remainingBalance` are **always the string `"0"` even on a charge that succeeded** — `creditsRedeemed > 0` there reports a real charge as a decline, and on a card rail that invites a retry of a payment that already went through. - Both fields are **strings**: `"0"` is truthy while `Number("0") > 0` is false, so two plausible checks disagree. - - **No `billingModel` at all?** The deployment predates the discriminator — apply the `credits` rule, never pay-as-you-go. If `creditsRedeemed` is missing too, `success: true` is the whole answer. + - **No `billingModel` at all?** The deployment predates the discriminator — apply the `credits` rule, never pay-as-you-go. - **Card budget caveat:** a card settle may not immediately move the delegation's `amountSpentCents`/`remainingBudgetCents` — use the settle receipt + the A5 plan balance as the source of truth for card spend, not the delegation budget. **Calling a protected agent directly** (the common case): just send the access token as the `payment-signature` header to the agent's endpoint — the agent's own `402` response **is** your `paymentRequired`, and the agent verifies + settles for you. You only call `/settle` yourself when topping up a plan with no protected endpoint to hit. diff --git a/skills/nevermined-payments/references/autonomous-operations.md b/skills/nevermined-payments/references/autonomous-operations.md index 4121263f..24da6f6d 100644 --- a/skills/nevermined-payments/references/autonomous-operations.md +++ b/skills/nevermined-payments/references/autonomous-operations.md @@ -215,7 +215,7 @@ Response (`X402SettleResponseDto`): Checking `creditsRedeemed > 0` on such a plan reports a real charge as a decline — and on a card rail that invites a retry of a payment that already went through. Both fields are **strings**, so `"0"` is truthy while `Number("0") > 0` is false. -If the response carries **no `billingModel` at all**, the deployment predates the discriminator: apply the `credits` rule, and never read a missing discriminator as pay-as-you-go. All three fields are optional — if `creditsRedeemed` is absent too there is no balance to check, and `success: true` is the whole answer. +If the response carries **no `billingModel` at all**, the deployment predates the discriminator: apply the `credits` rule, and never read a missing discriminator as pay-as-you-go. For a plan top-up with no protected endpoint, set `resource.url` to the plan's own URL — `{API_BASE}/api/v1/protocol/plans/`. diff --git a/skills/nevermined-payments/references/client-integration.md b/skills/nevermined-payments/references/client-integration.md index 30bc00ee..368e903d 100644 --- a/skills/nevermined-payments/references/client-integration.md +++ b/skills/nevermined-payments/references/client-integration.md @@ -37,7 +37,7 @@ curl -s -X POST -H "Authorization: Bearer $NVM_API_KEY" -H "Content-Type: applic - **Card payment:** switch `scheme` to `nvm:card-delegation` and `network` to `stripe` (or `braintree`/`visa`) in both calls. - **Calling a protected agent directly:** skip building `paymentRequired` — send the access token as the `payment-signature` header; the agent settles for you and returns the receipt in the `payment-response` header. -- **Proof of purchase — read `billingModel` first.** On a `credits` plan it is `success: true` and `creditsRedeemed > 0`. On a `pay-as-you-go` plan there is no credit balance, so `creditsRedeemed` and `remainingBalance` are always the string `"0"` even on a successful charge; the proof is `success: true` plus a non-empty `orderTx` (fiat) or `transaction` (crypto). Never gate on `creditsRedeemed` alone — on a card rail it reports a real charge as a decline and invites a retry. If `billingModel` is missing entirely the deployment predates it: apply the `credits` rule, and if `creditsRedeemed` is missing too, `success: true` is the whole answer. +- **Proof of purchase — read `billingModel` first.** On a `credits` plan it is `success: true` and `creditsRedeemed > 0`. On a `pay-as-you-go` plan there is no credit balance, so `creditsRedeemed` and `remainingBalance` are always the string `"0"` even on a successful charge; the proof is `success: true` plus a non-empty `orderTx` (fiat) or `transaction` (crypto). Never gate on `creditsRedeemed` alone — on a card rail it reports a real charge as a decline and invites a retry. If `billingModel` is missing entirely the deployment predates it: apply the `credits` rule. Full runbook with API-key retrieval, card enrollment, and status checks: `autonomous-operations.md`. diff --git a/specs/x402-card-delegation.mdx b/specs/x402-card-delegation.mdx index da797baf..aaaf8ad8 100644 --- a/specs/x402-card-delegation.mdx +++ b/specs/x402-card-delegation.mdx @@ -830,9 +830,7 @@ Additional settlement details MAY be included in the response body: **No `billingModel` in the response?** The deployment predates the discriminator. Apply the - **credits** rule — a missing discriminator MUST NOT be read as pay-as-you-go. All three fields - are optional; if `creditsRedeemed` is absent too there is no balance information to check, and - `success === true` is the whole answer. + **credits** rule — a missing discriminator MUST NOT be read as pay-as-you-go. diff --git a/specs/x402-smart-accounts.mdx b/specs/x402-smart-accounts.mdx index c85d69ca..b85d4076 100644 --- a/specs/x402-smart-accounts.mdx +++ b/specs/x402-smart-accounts.mdx @@ -636,9 +636,7 @@ Additional settlement details MAY be included in the response body: **No `billingModel` in the response?** The deployment predates the discriminator. Apply the - **credits** rule — a missing discriminator MUST NOT be read as pay-as-you-go. All three fields - are optional; if `creditsRedeemed` is absent too there is no balance information to check, and - `success === true` is the whole answer. + **credits** rule — a missing discriminator MUST NOT be read as pay-as-you-go.