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..04a3f51e 100644 --- a/development-guide/nevermined-x402.mdx +++ b/development-guide/nevermined-x402.mdx @@ -572,11 +572,16 @@ 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, - transactionHash: settlement.txHash, + orderTx: settlement.orderTx, + transactionHash: settlement.transaction, })).toString('base64') res.set('payment-response', settlementReceipt) @@ -594,11 +599,16 @@ 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, - "transactionHash": settlement.tx_hash, + "orderTx": settlement.order_tx, + "transactionHash": settlement.transaction, }).encode()).decode() response.headers["payment-response"] = settlement_receipt 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/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..b274d276 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) @@ -381,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')) @@ -401,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/SKILL.md b/skills/nevermined-payments/SKILL.md index d7262fa6..8d531175 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,11 @@ 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. + - **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. @@ -246,7 +250,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..24da6f6d 100644 --- a/skills/nevermined-payments/references/autonomous-operations.md +++ b/skills/nevermined-payments/references/autonomous-operations.md @@ -190,12 +190,34 @@ 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. + +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/`. ### 4c. Dry-run (optional) diff --git a/skills/nevermined-payments/references/client-integration.md b/skills/nevermined-payments/references/client-integration.md index b7be8d62..368e903d 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. 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`. @@ -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/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) => { 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..aaaf8ad8 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,31 @@ 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 | + + + **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. + + + +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..b85d4076 100644 --- a/specs/x402-smart-accounts.mdx +++ b/specs/x402-smart-accounts.mdx @@ -620,10 +620,31 @@ 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 | + + + **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. + + + +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