Skip to content
Merged
5 changes: 5 additions & 0 deletions .amazonq/rules/nevermined-router.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,11 @@ Budget is debited in **whole cents, rounded up** — 1000 calls at $0.001 costs
- `BCK.ROUTER.0008` (403) — legacy API key; create a new one.
- Only `BCK.ROUTER.0006` (500) and `0007` (429, too many concurrent) are **retryable**. Everything
else is a decision — retrying unchanged gives the same answer.
- `BCK.ROUTER.0010` (500) — internal. **Never blind-retry it:** a credential was already minted and
no record was written, so `requestId` will not suppress the retry. Report it.
- `BCK.ROUTER.0011` (402) — card rail: needs cardholder 3-D Secure, which an agent can't complete.
Nothing was charged. **Don't auto-retry** — each attempt re-demands 3DS and strands a fresh
single-use card credential. Surface it to a human.

**Never widen a Delegation, and never create a second one, to get past a refusal.** The cap is the
user's decision, not a runtime obstacle; minting a fresh Delegation to escape an exhausted one
Expand Down
5 changes: 5 additions & 0 deletions .clinerules/nevermined-router.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,11 @@ Budget is debited in **whole cents, rounded up** — 1000 calls at $0.001 costs
- `BCK.ROUTER.0008` (403) — legacy API key; create a new one.
- Only `BCK.ROUTER.0006` (500) and `0007` (429, too many concurrent) are **retryable**. Everything
else is a decision — retrying unchanged gives the same answer.
- `BCK.ROUTER.0010` (500) — internal. **Never blind-retry it:** a credential was already minted and
no record was written, so `requestId` will not suppress the retry. Report it.
- `BCK.ROUTER.0011` (402) — card rail: needs cardholder 3-D Secure, which an agent can't complete.
Nothing was charged. **Don't auto-retry** — each attempt re-demands 3DS and strands a fresh
single-use card credential. Surface it to a human.

**Never widen a Delegation, and never create a second one, to get past a refusal.** The cap is the
user's decision, not a runtime obstacle; minting a fresh Delegation to escape an exhausted one
Expand Down
5 changes: 5 additions & 0 deletions .cursor/rules/nevermined-router.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,11 @@ Budget is debited in **whole cents, rounded up** — 1000 calls at $0.001 costs
- `BCK.ROUTER.0008` (403) — legacy API key; create a new one.
- Only `BCK.ROUTER.0006` (500) and `0007` (429, too many concurrent) are **retryable**. Everything
else is a decision — retrying unchanged gives the same answer.
- `BCK.ROUTER.0010` (500) — internal. **Never blind-retry it:** a credential was already minted and
no record was written, so `requestId` will not suppress the retry. Report it.
- `BCK.ROUTER.0011` (402) — card rail: needs cardholder 3-D Secure, which an agent can't complete.
Nothing was charged. **Don't auto-retry** — each attempt re-demands 3DS and strands a fresh
single-use card credential. Surface it to a human.

**Never widen a Delegation, and never create a second one, to get past a refusal.** The cap is the
user's decision, not a runtime obstacle; minting a fresh Delegation to escape an exhausted one
Expand Down
5 changes: 5 additions & 0 deletions .windsurf/rules/nevermined-router.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,11 @@ Budget is debited in **whole cents, rounded up** — 1000 calls at $0.001 costs
- `BCK.ROUTER.0008` (403) — legacy API key; create a new one.
- Only `BCK.ROUTER.0006` (500) and `0007` (429, too many concurrent) are **retryable**. Everything
else is a decision — retrying unchanged gives the same answer.
- `BCK.ROUTER.0010` (500) — internal. **Never blind-retry it:** a credential was already minted and
no record was written, so `requestId` will not suppress the retry. Report it.
- `BCK.ROUTER.0011` (402) — card rail: needs cardholder 3-D Secure, which an agent can't complete.
Nothing was charged. **Don't auto-retry** — each attempt re-demands 3DS and strands a fresh
single-use card credential. Surface it to a human.

**Never widen a Delegation, and never create a second one, to get past a refusal.** The cap is the
user's decision, not a runtime obstacle; minting a fresh Delegation to escape an exhausted one
Expand Down
16 changes: 14 additions & 2 deletions products/router/guardrails.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
The Router signs payments on your behalf, from your wallet, in response to instructions written by a merchant you may never have heard of. So it is deliberately suspicious. This page is the complete list of what it checks — and, more usefully, what to do when it says no.

<Note>
**A refusal is the system working.** Most Router errors mean a guardrail caught something. Before you widen a cap or drop an idempotency key to make an error go away, read what it was actually protecting you from.

Check warning on line 10 in products/router/guardrails.mdx

View check run for this annotation

Mintlify / Mintlify Validation (neverminedag) - vale-spellcheck

products/router/guardrails.mdx#L10

Did you really mean 'idempotency'?
</Note>

## Your budget
Expand Down Expand Up @@ -52,18 +52,18 @@

## Paying twice

`requestId` is the idempotency key, and it's **required** in mode B — because the Router pays automatically, a retry after a dropped connection must not buy the same thing again.

Check warning on line 55 in products/router/guardrails.mdx

View check run for this annotation

Mintlify / Mintlify Validation (neverminedag) - vale-spellcheck

products/router/guardrails.mdx#L55

Did you really mean 'idempotency'?

At most one payment is minted per `(caller, requestId)`. A duplicate returns **`409 BCK.ROUTER.0002`** carrying the original `paymentId`, which is usually what you actually wanted.

Use **one stable id per logical purchase**, not per HTTP attempt:

- Retrying a timed-out call with the **same** id → returns the original payment. Safe.
- Retrying a timed-out call with the **same** id → `409 BCK.ROUTER.0002` with the original `paymentId`, **not the resource**. Safe — but don't escape that 409 by minting a fresh id.
- Retrying with a **fresh** id → buys again. Also safe, but only if that's what you meant.

## What the Router won't fetch

The Router makes server-side HTTP requests to URLs you supply, so it will not let you point it at infrastructure you shouldn't reach. Targets resolving to loopback, private (RFC 1918), link-local, or cloud-metadata addresses are blocked — both literal IPs and public hostnames that *resolve* to internal addresses, so DNS rebinding doesn't get around it. The connection is then pinned to the address that was validated, so it can't be swapped underneath.

Check warning on line 66 in products/router/guardrails.mdx

View check run for this annotation

Mintlify / Mintlify Validation (neverminedag) - vale-spellcheck

products/router/guardrails.mdx#L66

Did you really mean 'loopback'?

Check warning on line 66 in products/router/guardrails.mdx

View check run for this annotation

Mintlify / Mintlify Validation (neverminedag) - vale-spellcheck

products/router/guardrails.mdx#L66

Did you really mean 'IPs'?

Check warning on line 66 in products/router/guardrails.mdx

View check run for this annotation

Mintlify / Mintlify Validation (neverminedag) - vale-spellcheck

products/router/guardrails.mdx#L66

Did you really mean 'hostnames'?

Redirects are **not followed at all**, and the `location` header is stripped from the relayed response — so a merchant can't bounce the Router toward an internal target, and can't hand your client one either.

Expand All @@ -82,23 +82,35 @@
| Total time on a streamed response | 5 min | `ROUTER_STREAM_MAX_MS` |
| Relayed body size | 100 MB | `ROUTER_MAX_RELAY_BYTES` |

Exceeding the concurrency limit returns **`429 BCK.ROUTER.0007`**, which *is* retryable — let some calls finish and try again. The idle timer is re-armed by your client draining the response, so a slow-but-healthy large transfer won't trip it.

Check warning on line 85 in products/router/guardrails.mdx

View check run for this annotation

Mintlify / Mintlify Validation (neverminedag) - vale-spellcheck

products/router/guardrails.mdx#L85

Did you really mean 'retryable'?

## Every error code

| Code | Status | Meaning | Retry? |
| --- | --- | --- | --- |
| `BCK.ROUTER.0001` | 400 | Bad input: unsupported protocol, malformed or empty challenge, no fundable option, a recipient outside the Delegation's scope, a non-allowlisted asset, a wrong-provider Delegation, or a missing `delegationId`. The `details` field names the specific problem. | No |

Check warning on line 91 in products/router/guardrails.mdx

View check run for this annotation

Mintlify / Mintlify Validation (neverminedag) - vale-spellcheck

products/router/guardrails.mdx#L91

Did you really mean 'fundable'?
| `BCK.ROUTER.0002` | 409 | This `requestId` already minted a payment. The original `paymentId` is in the response. | No |
| `BCK.ROUTER.0003` | 402 | The Delegation is over cap, expired, exhausted, or revoked. | No |
| `BCK.ROUTER.0004` | 404 | No Router payment with that id belongs to you. | No |
| `BCK.ROUTER.0005` | 409 | The payment isn't in a settleable state. Only an `Issued` payment can be marked `Settled`; re-reporting the same hash is a no-op, a different hash is rejected. | No |

Check warning on line 95 in products/router/guardrails.mdx

View check run for this annotation

Mintlify / Mintlify Validation (neverminedag) - vale-spellcheck

products/router/guardrails.mdx#L95

Did you really mean 'settleable'?
| `BCK.ROUTER.0006` | 500 | Transient failure building the payments summary. | **Yes** |
| `BCK.ROUTER.0007` | 429 | Too many concurrent routed requests in flight. | **Yes** |
| `BCK.ROUTER.0008` | 403 | Legacy API key. Create a new one — see [the quickstart](/products/router/quickstart#1-get-an-api-key). | No |
| `BCK.ROUTER.0009` | 402 | You couldn't fund the payment. MPP only, and it covers two cases: MPP-tempo read your balance and found it short, or MPP-stripe attempted your card and it declined. Either way nothing was signed and no budget was reserved. x402 never raises this — it has no balance check, so a short wallet fails on-chain instead. | No |
| `BCK.ROUTER.0010` | 500 | Internal: a rail reported a charge amount the Router can't reserve against the cap. | No — **never blind-retry** |
| `BCK.ROUTER.0011` | 402 | Card rail: the charge needs cardholder 3-D Secure, and an agent has no browser to complete it. Nothing was charged and the seller got no usable credential. | No — **needs a human** |

Only `0006` and `0007` are worth retrying automatically. The rest are decisions, and retrying them unchanged will produce the same answer.
Across the whole Router surface, only `0006` and `0007` are worth retrying automatically. The rest are decisions, and retrying them unchanged will produce the same answer. On the *paying* path the set is narrower still — see below.

<Warning>
**`0010` is a 500 that must never be retried** — which makes it the exception to "retry the 5xx". In fact `0006`, the one retryable 500, is raised only by the payments *summary* read and never by a payment, so on the paying path **`0007` is the only code worth retrying at all**.

Check warning on line 106 in products/router/guardrails.mdx

View check run for this annotation

Mintlify / Mintlify Validation (neverminedag) - vale-spellcheck

products/router/guardrails.mdx#L106

Did you really mean 'retryable'?
Comment thread
r-marques marked this conversation as resolved.

`0010` fails *after* a payment credential has already been minted, and because no payment record was written, your `requestId` can't suppress a retry. Idempotency is enforced against the record, and there isn't one — so a retry is treated as a fresh purchase, mints another credential, and then fails identically, because the cause is a deterministic defect rather than a transient blip.

Check warning on line 108 in products/router/guardrails.mdx

View check run for this annotation

Mintlify / Mintlify Validation (neverminedag) - vale-spellcheck

products/router/guardrails.mdx#L108

Did you really mean 'Idempotency'?

**And `0011` is its mirror image on the other side of the status code.** A card needing 3-D Secure returns a `402` that reads like a routine payment error, and is equally un-retryable: the Router can't complete the challenge, so every retry re-demands it and strands another single-use card credential. Route it to a human. The general lesson is that **the HTTP status does not tell you whether to retry** — read the code.

Nothing was charged to your Delegation and no payment record exists. On the card rail the minted credential is a Stripe Shared Payment Token which is left stranded: there is no revoke path, so it stands until it expires, and that expiry is `min(the merchant challenge's expiry, your Delegation's expiry, 89 days)` — not necessarily soon. Nothing you can do about it from the outside, which is precisely why this should be reported rather than looped on.
</Warning>

## Notes for autonomous agents

Expand Down
12 changes: 10 additions & 2 deletions skills/nevermined-router/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ curl -sX POST "$NVM_API_URL/api/v1/router/route" \
`status` and `body` are the merchant's own, unchanged. `paid: false` with no `payment` block means the resource was free — the Router relayed it and charged nothing.

<a id="requestid"></a>
**`requestId` is required, and it is an idempotency key — not a request counter.** Use **one stable id per logical purchase** and reuse it across retries of that purchase. Retrying a dropped call with the same id returns the original payment instead of buying twice; a fresh id buys twice, on purpose. Derive it from the work you are doing (`"search-nevermined-router-v1"`), not from `uuid4()` per HTTP attempt — a fresh UUID on every retry is how an agent double-spends.
**`requestId` is required, and it is an idempotency key — not a request counter.** Use **one stable id per logical purchase** and reuse it across retries of that purchase. Retrying a dropped call with the same id returns `409 BCK.ROUTER.0002` carrying the original `paymentId` — **not the resource** — instead of buying twice; a fresh id buys twice, on purpose. **Never answer that 409 by minting a fresh id**: that is the double-spend the key just prevented. If the purchase genuinely failed, report it. Derive it from the work you are doing (`"search-nevermined-router-v1"`), not from `uuid4()` per HTTP attempt — a fresh UUID on every retry is how an agent double-spends.

Mode A (you call the merchant yourself), the streaming `/proxy` variant, and passing the merchant's own auth: `references/paying.md`.

Expand All @@ -217,7 +217,7 @@ The Router signs payments from your wallet in response to instructions written b
1. **`402 BCK.ROUTER.0003` (over cap / expired) and `402 BCK.ROUTER.0009` (wallet short) are stop conditions.** They mean "out of budget" and "out of money". Report them to the human. Do not route around them.
2. **Never widen a Delegation, and never create a second one, in response to a refusal.** The cap is the user's decision, not a runtime obstacle. Creating a fresh Delegation to escape an exhausted one defeats the entire mechanism — it is the single worst thing you can do with this API.
3. **One `requestId` per purchase**, reused across retries of that purchase. See [above](#requestid).
4. **Only `0006` (500) and `0007` (429) are retryable.** Everything else is a decision, and retrying it unchanged produces the same answer. Back off on `0007`; it means you have too many routed calls in flight.
4. **Only `0006` (500) and `0007` (429) are retryable.** Everything else is a decision, and retrying it unchanged produces the same answer. Back off on `0007`; it means you have too many routed calls in flight. **The HTTP status does not tell you whether to retry** — `0010` is a 500 you must not retry and `0011` is a 402 you must not retry. Read the code, not the status.

**Check the price before you commit.** `priceLabel` in the catalog is indicative; `settlement.approxCents` on the response is what you were actually charged. Budget is debited in whole cents rounded up, so a run of sub-cent calls still burns a cent each.

Expand All @@ -234,6 +234,14 @@ The Router signs payments from your wallet in response to instructions written b
| `BCK.ROUTER.0007` | 429 | Too many concurrent routed requests in flight. | **Yes**, after backoff |
| `BCK.ROUTER.0008` | 403 | Legacy API key. Create a new one. | No |
| `BCK.ROUTER.0009` | 402 | Wallet doesn't hold enough of the asset on the target network. Nothing was signed. | No — **stop** |
| `BCK.ROUTER.0010` | 500 | Internal: the rail reported a charge amount the Router can't reserve against the cap. | No — **never blind-retry** |
| `BCK.ROUTER.0011` | 402 | Card rail: the charge needs cardholder 3-D Secure, and an agent has no browser to complete it. Nothing was charged and the seller got no usable credential. | No — **needs a human** |

**`0011` needs a human, not a retry.** The card issuer is demanding 3-D Secure and the Router has no browser to answer it. Nothing was charged. Do **not** loop: 3DS is often mandated per charge, so every attempt re-demands it and mints a fresh single-use card credential that is then abandoned. A later *human-driven* attempt may succeed — that is a decision, not a retry.

**`0010` is the one 500 you must never retry.** A payment credential **was already minted** before it failed — and because no payment record was written, your `requestId` will *not* suppress the retry. So a retry mints a **fresh** credential and then fails identically, because the cause is a deterministic defect in the rail's amount derivation, not a transient blip. Report it to the human.

Note `0006`, the retryable 500, is only ever raised by the payments *summary* read — never by a payment. **On the paying path `0007` is the only code worth retrying at all.** And seeing `0010` at all means a Nevermined-side regression: no rail emits a non-numeric amount today, so it is a bug report, not a condition to handle. (On the card rail the minted credential is a Stripe Shared Payment Token, left stranded with no revoke path until `min(challenge expiry, Delegation expiry, 89 days)`.)

Catalog errors: `BCK.CATALOG.0001` (404, no listed service with that slug — slugs are case-sensitive), `BCK.CATALOG.0002` (500, transient, retryable), `BCK.CATALOG.0003` (400, `protocol` filter must be one of `x402`, `mpp`, `rest`, `a2a`, `other`).

Expand Down
46 changes: 46 additions & 0 deletions skills/nevermined-router/references/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,56 @@ obstacles is exactly the failure mode this design exists to prevent.
| `BCK.ROUTER.0007` | 429 | Too many concurrent routed requests in flight | **Yes**, after backoff |
| `BCK.ROUTER.0008` | 403 | Legacy API key — create a new one | No |
| `BCK.ROUTER.0009` | 402 | Wallet doesn't hold enough of the asset on the target network. **Nothing was signed** | No — **stop** |
| `BCK.ROUTER.0010` | 500 | Internal: the rail reported a charge amount that isn't a non-negative integer, so the Router can't reserve anything against the cap | No — **never blind-retry** |
Comment thread
r-marques marked this conversation as resolved.
| `BCK.ROUTER.0011` | 402 | Card rail: the charge needs cardholder 3-D Secure, and an agent has no browser to complete it. Nothing was charged and the seller got no usable credential. | No — **needs a human** |

**Only `0006` and `0007` are worth retrying automatically.** The rest are decisions; retrying them
unchanged produces the same answer.

### `0011` — the 402 that needs a human, not a retry

Card rail only. The issuer demands **3-D Secure / SCA** before the charge can be used, and the Router
has no human at a browser to complete it. **Nothing was charged, and the seller never received a
usable credential** — the one Stripe created cannot be charged while it awaits authentication.

Do **not** auto-retry. 3DS is often mandated per charge by industry rules, so every attempt
re-demands it and mints another single-use card credential that is then abandoned — each expiring on
its own at `min(the merchant's quoted expiry, your Delegation's expiry, 89 days)`. A later attempt
*may* succeed, since whether authentication is demanded is decided per charge by the issuer, the card
networks and Stripe's risk checks — but treat that as a human decision, not a loop.

It is distinct from both other 402s: `0003` is your cap, `0009` is a card refused for lack of funds.
Here the card is fine; it simply has not been authenticated for this charge.

### `0010` — the 500 you must not retry

`0006` and `0010` are both 500s and behave in opposite ways, so "retry 5xx" is the wrong reflex
here. Note also that `0006` is raised **only by the payments summary read**, never by a payment —
so on the paying path, `0007` is the only code worth retrying at all.

`0010` means a payment handler reported a settlement amount in cents that isn't a non-negative
integer, so the routing-fee arithmetic can't compute what to reserve. It deliberately fails rather
than defaulting to zero — reserving nothing would let the payment through free.

What that leaves behind is the important part:

- **No budget was reserved and no payment record was written.**
- **But a payment credential WAS already minted**, because the fee is quoted after the signing step.
- **Therefore your `requestId` cannot protect you.** Idempotency is enforced against the payment
record, and there is no record — so a retry is treated as a brand-new purchase and mints a
**fresh** credential.
- The cause is a deterministic defect in that rail's `approxCents` derivation, not a transient
blip, so the retry fails in exactly the same way.

**How much that actually costs you depends on the rail.** On the crypto rails the credential never
leaves the Router process on this path, so no funds can move and nothing is at risk. On the card
rail it is a Stripe Shared Payment Token that is left **stranded**: there is no revoke path, so it
stands until `min(the merchant challenge's expiry, your Delegation's expiry, 89 days)`. You cannot
clean it up from the outside.

**Seeing `0010` at all is a Nevermined-side regression.** No rail emits a non-numeric amount today,
so this is a bug to report, not a condition to handle. Report it to the human. Do not loop.

Catalog codes: `BCK.CATALOG.0001` (404, unknown slug — case-sensitive), `BCK.CATALOG.0002` (500,
transient, retryable), `BCK.CATALOG.0003` (400, bad `protocol` filter).

Expand Down
2 changes: 1 addition & 1 deletion skills/nevermined-router/references/paying.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ you actually wanted.

**Use one stable id per logical purchase, reused across retries of that purchase.**

- Same id on retry → returns the original payment. Safe.
- Same id on retry → `409 BCK.ROUTER.0002` with the original `paymentId`, **not the resource**. Safe — and never escape that 409 with a fresh id.
- Fresh id on retry → buys again. Also safe, *if that is what you meant*.

Derive it from the work (`"search-nevermined-router-v1"`, a hash of the query, a task id). **A fresh
Expand Down