---
title: "Budgets on superstables.com (testnet)"
description: "An agent with its own key asks its owner, through superstables.com, for a budget it can spend without asking again: a USDC allowance, a Tempo access key or a Solana delegate. The owner approves in their own wallet. Testnet only."
canonical: "https://www.superstables.com/docs/budgets"
last-updated: 2026-10-04
---

# Budgets on superstables.com (testnet)

For owner approval and account steps, start with the [owner guide](https://www.superstables.com/docs/owner.md#approve-a-budget).
To run the client, start with [Use a budget](https://www.superstables.com/docs/client/budget.md): local approval comes first, with
approval on superstables.com as a choice in the same guide. This secondary HTTP reference describes signed
agent requests; approval on superstables.com does not host the agent runtime.

**Testnet only.** Budgets are test USDC on six EVM testnets (Base Sepolia, Arc Testnet, Arbitrum
Sepolia, Polygon Amoy, SKALE Base Sepolia and Ethereum Sepolia) and on Solana devnet, and test pathUSD on Tempo Moderato.
No real money moves.

A budget is what the owner's wallet lets the agent spend, without asking again, up to a cap:

| `rail` | `chain` | The budget | What the chain enforces |
| --- | --- | --- | --- |
| `evm` | `base-sepolia`, `arc-testnet`, `arbitrum-sepolia`, `polygon-amoy`, `skale-base-sepolia`, `ethereum-sepolia` | An ERC-20 allowance: `approve(agent, cap)` on the chain's USDC | The total cap |
| `tempo` | `moderato` | An access key: `authorizeKey` on Tempo's keychain, with a pathUSD limit and an expiry | The limit (in total, or per period), the expiry and any seller list |
| `solana` | `devnet` | An SPL token delegate: `ApproveChecked` on the owner's USDC account | The total cap |

The agent keeps its own key. The budget service on superstables.com never holds or uses a key that can move money: it
passes the agent's request to the owner, decodes the transaction, shows the owner what the chain will and
will not enforce, and reads the outcome from the chain.

This page is the HTTP contract, for anyone building an agent that uses it. The [Superstables client](https://www.superstables.com/docs/client.md)
0.3.0 uses it after `superstables budget setup --hosted`; without `--hosted`, its budget approvals use a page
on the owner's own machine, described in [Budget](https://www.superstables.com/docs/client/budget.md). Budget approvals on superstables.com support Base
Sepolia, Arc Testnet, Arbitrum Sepolia, Polygon Amoy, SKALE Base Sepolia, Ethereum Sepolia, Tempo Moderato
and Solana devnet.

- **On superstables.com** (this page): the owner approves on superstables.com, signed in with their wallet. There is no
  separate sign-up: the owner signs in with Ethereum on the first approval page. On Solana they also
  connect a Solana wallet, which holds the budget.
- **Local**: the client serves the approval page on the owner's own machine (127.0.0.1), with no account.
  `recover` is local only.

What the chain does not enforce on `evm` and `solana`: an expiry, a seller list or a per-payment limit.
Whoever holds the agent's key can move the budget to any address, up to the cap. On `tempo` the chain
also enforces the key's expiry and, when the grant names one, its seller list; not a per-payment limit.
The owner can revoke a budget at any time from https://www.superstables.com/account. To change a budget,
revoke it, then ask for a new grant. A revoked Tempo key can never be granted again: a new budget there
needs a new agent key.

## The flow

1. The agent makes an **add-agent request** (`kind: "link"`) through `POST /api/v1/budget/links`. The owner
   opens the approval link, signs in, picks the match code and adds the agent to their account. Adding the
   agent moves no funds.
2. The agent asks the owner to **send one transaction** (`POST /api/v1/budget/approvals`): a grant, a
   revoke or a gas top-up. The owner opens the approval link, picks the code and approves in their wallet, which must
   use the owner's account address. The wallet sends exactly the transaction the agent asked for.
3. The agent **reads the request** (`GET /api/v1/budget/requests/{id}?wait=20`) until `final` is `true`,
   then reads the chain itself before it reports success.

To set an agent up in one go, the add-agent request can carry the gas top-up and the grant (`then`, below):
the owner adds the agent and approves both transactions on one page, and the agent waits on one request.

## Signing a request

Requests that create something carry a signature by the agent's key (agent request proof v2), in four
headers:

```http
Superstables-Agent: 0x<agent address>
Superstables-Agent-Timestamp: <unix seconds>
Superstables-Agent-Nonce: <32 lowercase hex characters>
Superstables-Agent-Signature: 0x<EIP-191 personal_sign signature>
```

On `evm` and `tempo` the agent key is an EVM key. On `solana` it is an ed25519 key:
`Superstables-Agent` is its public key in base58, and `Superstables-Agent-Signature` is `0x` and the
64-byte ed25519 signature, in hex, over the UTF-8 bytes of the same six lines. The body's `agent` must be
the same key, exactly as written. The nonce is 16 random bytes in lowercase hex, new for every request.

The signed text is exactly these six lines, separated by `\n`, with no trailing newline:

```text
Superstables agent request v2
origin: https://www.superstables.com
POST /api/v1/budget/links
<SHA-256 of the raw request body, lower-case hex>
<the same unix seconds as the header>
<the same nonce as the header>
```

The `origin:` line is the origin of the site you send the request to, with no trailing slash. Each site
checks it against its own configured origin, not against the request's `Host`, so a request signed for one
site is refused by any other. The path never includes a query string.

The server checks, in this order: the timestamp is within 300 seconds of its clock; the nonce is 32 lowercase
hex characters; the signature is the agent's over the six lines it rebuilds from its own origin and the
method, path and body it received; the signer is the key in `Superstables-Agent` and in the body's `agent`;
and the nonce has not been used by this agent in the last 10 minutes. Any failure is `401` with the code
`agent_proof` and a `reason`: `missing`, `malformed`, `stale`, `nonce`, `signature` (also a wrong origin,
method, path or body), `agent_mismatch` or `replayed`. A nonce is used once every other check has passed,
so a refused request does not use it up.

Each signed request is accepted once. Sent again unchanged, it is refused: `409 proof_reused`, naming the
request it made, or `401 agent_proof` (`replayed`) when it made none. For a retry, sign again with a new
nonce and the current time.

```ts
import { createHash, randomBytes } from "node:crypto";
import { privateKeyToAccount } from "viem/accounts";

const agent = privateKeyToAccount(process.env.AGENT_KEY as `0x${string}`);
const origin = "https://www.superstables.com";
const path = "/api/v1/budget/links";
const body = JSON.stringify({ rail: "evm", chain: "base-sepolia", agent: agent.address });
const ts = String(Math.floor(Date.now() / 1000));
const nonce = randomBytes(16).toString("hex");
const sum = createHash("sha256").update(body).digest("hex");
const signature = await agent.signMessage({
  message: ["Superstables agent request v2", `origin: ${origin}`, `POST ${path}`, sum, ts, nonce].join("\n"),
});

await fetch(`${origin}${path}`, {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
    "Superstables-Agent": agent.address,
    "Superstables-Agent-Timestamp": ts,
    "Superstables-Agent-Nonce": nonce,
    "Superstables-Agent-Signature": signature,
  },
  body,
});
```

Send the body bytes you signed. `Idempotency-Key` is optional. Within an hour of creation, a retry with the same
key and body, signed with a new nonce and timestamp, returns `409 duplicate_request` with the original request's `id` and
`state`. Read it with the original `access_token`; no token is issued twice. A different body returns
`422 idempotency_key_reused`. After an hour, the key returns `422 idempotency_key_expired`.

## 1. Add the agent

`POST /api/v1/budget/links`

```json
{ "rail": "evm", "chain": "base-sepolia", "agent": "0x…", "label": "optional, up to 40 characters" }
```

`rail` and `chain` are one of the pairs in the table above: `evm` with `base-sepolia`, `arc-testnet`,
`arbitrum-sepolia`, `polygon-amoy`, `skale-base-sepolia` or `ethereum-sepolia`; `tempo` with `moderato`; `solana` with
`devnet`. Any other chain, mainnets included, is refused (`400 unsupported_chain`), and so is any other
rail, or a rail that does not match the agent's key (`400 unsupported_rail`).
An agent belongs to one account at most: to add it to another, its owner removes it from their account first.

If the agent has already been added on that chain, for example when setup runs again or stopped before it
recorded the add-agent request, the answer is a new final request recording the existing account
relationship: `200` with `state: "linked"`, `final: true`, `already_linked: true`, `owner` (the owner's account address),
`owner_proof` (the owner's signature from when they added it, below) and an `access_token` to read it. There is no `approval.url` and the owner is not asked.
Check `owner` and `owner_proof` against the owner, request ID and match code you recorded before you rely on it.

The answer (`201`):

| Field | What it is |
| --- | --- |
| `id` | `bl_` and 32 hex characters. |
| `access_token` | Your bearer token for this request, starting `ssbt_test_`. Shown once. It reads and cancels the request; it cannot approve anything. |
| `approval.url` | The approval link for the owner, on superstables.com. Give it exactly as written, including the part after `#` (the owner's token, `ssba_test_…`). |
| `approval.match_code` | Six letters, such as `KPT-RWD`. The page offers three codes and the owner must pick this one; a wrong pick closes the request. Write it in your own message to the owner. |
| `approval.expires_at` | The owner has 10 minutes. |
| `message_for_owner` | A sentence you can send to the owner as it is, with the approval link and the code. |
| `next_action` | `{"type": "wait_for_owner", "poll": "/api/v1/budget/requests/<id>"}` |

The owner signs in with Ethereum (a message, not a transaction), picks the code and chooses **Add agent**.
The page then shows the agent and its chain, and asks the owner's wallet to sign the owner-proof
message. Without that signature the agent is not added. The request becomes `linked`, `owner`
is the account's address, and `owner_proof` holds the signature:

```text
Superstables: add an agent to my account
site: https://www.superstables.com
owner: <the owner: checksummed 0x address on evm and tempo; base58 Solana address on solana>
agent: <the agent, as in Superstables-Agent>
rail: <evm, tempo or solana>
chain: <the chain, for example base-sepolia, moderato or devnet>
request: <the bl_ id you created>
code: <the match code you received>
```

```json
"owner_proof": { "scheme": "eip191", "message": "<the exact text above>", "signature": "0x…" }
```

On `evm` and `tempo` the owner's EVM wallet signs it with EIP-191 personal_sign (`scheme: "eip191"`). On
`solana` the owner connects a Solana wallet (Phantom, or any Wallet Standard wallet), which signs the UTF-8
bytes of the text (`scheme: "ed25519"`, the signature as `0x` and 128 hex characters); `owner` is then that
Solana address, and the budget is on its USDC account.

Before you record anyone as the owner, rebuild the text from your own values (the site's origin, the `owner`
in the answer, your agent, rail, chain, the request ID and the match code from your add-agent request), require it to
equal `owner_proof.message` byte for byte, and verify the signature for that owner. The site checks the same
before it adds the agent, so it cannot name an owner on its own. The same `owner_proof` comes back with any
`already_linked` answer for that agent and chain, naming the request and code that added it.

### Add an agent, gas and a budget on one page

An add-agent request may name up to two transactions for the owner's wallet to send once the agent is added, in order:

```json
{ "rail": "evm", "chain": "base-sepolia", "agent": "0x…",
  "then": [
    { "kind": "fund_agent", "transaction": { "to": "0x<agent>", "data": "0x", "value": "0x38d7ea4c68000" } },
    { "kind": "grant", "transaction": { "to": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", "data": "0x095ea7b3…", "value": "0x0" } }
  ] }
```

On `solana` each step carries intent instead, as in section 2:
`{ "kind": "fund_agent", "solana": { "amount_atomic": "10000000" } }`. On `tempo` an add-agent request takes
only a `grant`: the agent needs no gas.

Each step is a `fund_agent` or a `grant`, each kind at most once, and is decoded and judged exactly like an
approval of that kind (section 2). A step that breaks a rule is refused with `400 invalid_then`, naming the
step (`step`) and the rule (`reason_code`), and nothing is created. Before the owner adds the agent, a
request can name a budget of up to 1,000 test tokens. When they add it, the configured budget ceiling
applies. By default, it is 100 test USDC, or 100 test pathUSD on Tempo. With owner controls off, the owner
cannot change it. A grant above the ceiling is then
refused (`rejected`, `cap_above_limit`) and the agent is still added. If the agent has already been added on
that chain, an add-agent request with `then` is refused (`409 already_linked`, naming `owner` and `owner_proof`): ask for each transaction with
`POST /api/v1/budget/approvals`.

The owner sees one page: the add-agent action, then each step with its terms. The owner signs to add the
agent, then their wallet is asked for each transaction in turn; each one is confirmed on chain before the next is asked. A step that
does not end `confirmed` (rejected, failed, unknown, expired) stops the rest, which end `skipped`; the agent
stays in the account. Each step goes through the same handoff as a single transaction, with its own start block, and holds
the agent's one open request while it runs.

The answer and every read list the steps:

```json
{ "id": "bl_…", "kind": "link", "state": "rejected", "link_state": "linked", "final": true, "owner": "0x…",
  "next_action": { "type": "verify_on_chain", "tx_hashes": ["0x…"] },
  "steps": [
    { "index": 0, "kind": "fund_agent", "state": "confirmed", "tx_hash": "0x…", "reason": null, "reason_code": null, "wallet_asked": true, "transaction": { … }, "terms": { … } },
    { "index": 1, "kind": "grant", "state": "rejected", "tx_hash": null, "reason": "the owner rejected this request on the approval page; nothing was sent", "reason_code": "owner_rejected", "wallet_asked": false, "transaction": { … }, "terms": { … } }
  ] }
```

A step's `state` is one of the states in section 3, or `queued` (its turn has not come) or `skipped` (never
asked, because adding the agent or an earlier step did not complete; nothing was sent). The request's
`state` follows `link_state` until the agent is added; then `confirmed` when every step is, else the state of
the first step that is not. `final` is `true` only when the add-agent action and every step are final. Read each `tx_hash` from the chain
yourself before you report success.

## 2. Ask for a transaction

`POST /api/v1/budget/approvals`

```json
{ "kind": "grant", "rail": "evm", "chain": "base-sepolia", "agent": "0x…",
  "transaction": { "to": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", "data": "0x095ea7b3…", "value": "0x0" } }
```

`data` is the encoded call. With the `agent` account from the signing example, a grant of a cap the owner chose:

```ts
import { encodeFunctionData, parseAbi, parseUnits } from "viem";

const cap = "5"; // USDC, chosen by the owner
const body = JSON.stringify({
  kind: "grant", rail: "evm", chain: "base-sepolia", agent: agent.address,
  transaction: {
    to: "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
    data: encodeFunctionData({
      abi: parseAbi(["function approve(address spender, uint256 amount)"]),
      functionName: "approve",
      args: [agent.address, parseUnits(cap, 6)],
    }),
    value: "0x0",
  },
});
```

Sign and send it to `/api/v1/budget/approvals` as in [Signing a request](#signing-a-request).

Refused unless the agent has been added on that chain (`403 agent_not_linked`). The owner is the account the
agent belongs to, named in `owner` from the start. The server decodes `transaction` itself and accepts exactly one of:

| `kind` | `to` | `data` | `value` |
| --- | --- | --- | --- |
| `grant` | the chain's USDC | `approve(agent, cap)`, cap above 0 and at most the budget ceiling (100 test USDC by default) | `0x0` |
| `revoke` | the chain's USDC | `approve(agent, 0)` | `0x0` |
| `fund_agent` | the agent | empty | above 0 and at most 0.05 of the native token (on Arc Testnet, where USDC pays gas, at most 1 USDC) |
| `fund_agent`, Arc Testnet only | the chain's USDC | `transfer(agent, amount)`, amount at most 1 USDC | `0x0` |
| `grant`, `tempo` | the keychain, `0xaAAAaaAA00000000000000000000000000000000` | `authorizeKey(agent, 0, config)` in the form the client writes it: a secp256k1 key, `enforceLimits`, one pathUSD limit (a period of 0 is a one-time limit), an expiry in the future, and either any call or a seller list of pathUSD `transfer` and `transferWithMemo` to the same sellers | `0x0` |
| `revoke`, `tempo` | the keychain | `revokeKey(agent)` | `0x0` |

Everything else is refused with `422` and a code naming the rule: `wrong_target`, `wrong_call`,
`wrong_spender`, `wrong_recipient`, `wrong_amount`, `value_not_zero`, `cap_zero`, `cap_above_limit`,
`unlimited_refused` (an approve of 2^256 - 1), `amount_zero`, `amount_above_limit`, `expiry_passed`.
On Tempo the ceiling applies to the most the key can move by expiry: the limit, times the number of
periods when it has one. There is no `fund_agent` on Tempo (`400 invalid_kind`). `transaction` takes
only `to`, `data` and `value`; the owner's wallet sets the rest. A `transaction` that is not well formed (another
key, an address or data that does not parse) is `400 invalid_transaction`.

The answer has the same shape as an add-agent request's, with `id` starting `ba_`, and `terms`: the server's decoding,
which is what the owner's page shows. Approving over a live allowance replaces it, and an agent watching the
chain could spend the old one first, so revoke before a new grant.

The owner opens the approval link, signs in, picks the code and approves in their wallet. The wallet must use
the owner's account address and is switched to the right chain (on Tempo, the page adds Tempo Testnet
(Moderato) if the wallet doesn't have it). It sends exactly the stored transaction and pays its fee, so the
owner needs a little of the chain's gas token (on Tempo, pathUSD). The page reports the transaction hash.

### On Solana: intent, not a transaction

A Solana transaction carries a blockhash that expires in about a minute, so the agent sends what it wants,
and the server builds the transaction when the owner is ready:

```json
{ "kind": "grant", "rail": "solana", "chain": "devnet", "agent": "<base58>", "solana": { "amount_atomic": "5000000" } }
```

| `kind` | `solana.amount_atomic` | The transaction the server builds |
| --- | --- | --- |
| `grant` | USDC in 6-decimal units, above 0 and at most the budget ceiling | SPL Token `ApproveChecked` on the owner's USDC account: delegate the agent, the amount, 6 decimals |
| `revoke` | none | SPL Token `Revoke` on the owner's USDC account |
| `fund_agent` | lamports, above 0 and at most 1 SOL | a System Program transfer from the owner to the agent |

The owner is the fee payer and the only signer. When the owner approves, the server builds the transaction
with a fresh blockhash, their wallet signs it (`solana:signTransaction`), and the server checks that it is
the transaction it built, signed by the owner's proven Solana address, before it sends it. A wallet may add a
compute-unit limit and price (a priority fee of at most 0.001 SOL); any other change is refused, and
nothing is sent. `tx_hash` is the transaction's signature, in base58. A grant replaces any other delegate
on the owner's USDC account, and a revoke that would remove another key's delegation is refused.

## 3. Read the request

```bash
curl "https://www.superstables.com/api/v1/budget/requests/<id>?wait=20" -H "Authorization: Bearer <access_token>"
```

`wait` holds the answer until the request is final, for up to 20 seconds. Repeat until `final` is `true`, in
the same turn, rather than ask the owner to say when they are done.

```json
{ "id": "ba_…", "kind": "grant", "state": "sent", "final": false, "owner": "0x…", "tx_hash": "0x…",
  "wallet_asked": true, "reason": null, "next_action": { "type": "wait_for_chain", "poll": "/api/v1/budget/requests/ba_…" } }
```

| `state` | Meaning |
| --- | --- |
| `awaiting_owner` | The owner has not acted yet. Nothing has been sent. |
| `sending` | The owner's page handed the transaction to their wallet (`wallet_asked: true`) and no hash is reported yet. It may have been sent. This happens once per request: the page never asks the wallet a second time. On Solana the wallet only signs and the server sends: until `tx_hash` is set nothing was sent, and the owner may sign again, or reject (`rejected`), or let it end `expired` (`not_signed`) after 30 minutes. |
| `linked` | Final. The agent has been added to `owner`. |
| `sent` | The owner's wallet returned `tx_hash`; the server is reading the chain. |
| `confirmed` | Final. The transaction succeeded on chain and matches the stored one: from the owner, and the same `to`, `data` and `value`. `next_action.type` is `verify_on_chain`: read the allowance or balance yourself before you report success. |
| `failed` | Final. The transaction reverted (`reason_code: reverted`), differs from the stored one (`mismatch`), was mined before the request (`stale_tx`), or, on Solana, expired before it landed (`not_landed`). |
| `unknown` | Final. The wallet was asked and the outcome is not known: no hash came back within 30 minutes (`wallet_timeout`), the owner stopped waiting (`owner_stopped`), or the reported transaction was not found on chain within 30 minutes (`not_found`). It may have been sent: read the chain. A hash the page reports later is recorded on it (`tx_hash`), and the chain then moves it to `confirmed` or `failed`. |
| `rejected` | Final, before the wallet was asked, so nothing was sent. The owner rejected it (`owner_rejected`), said they did not ask for it (`not_requested`: do not ask again unless they ask you to), picked a different code (`match_code_mismatch`), or removed the agent (`agent_removed`). |
| `expired` | Final. Nobody acted within 10 minutes (`approval_expired`), so nothing was sent. Once the wallet has been asked, the request stays open 30 minutes for its hash, then ends `unknown`. |
| `cancelled` | Final. You cancelled it (`agent_cancelled`). |
| `skipped` | Final, for a step of an add-agent request: never asked, so nothing was sent. |

## Cancel

`POST /api/v1/budget/requests/{id}/cancel` with the bearer token. Only while the owner's wallet has not been
asked: after that it is refused (`409 wallet_asked`, with `wallet_asked: true`), because the transaction may
already be on its way.

For an add-agent request with steps, once the agent has been added, cancel withdraws the steps the owner's
wallet has not been asked for: they end `cancelled` (`agent_cancelled`) and can no longer be sent. A step already handed to the
wallet stays as it is, because it may have been sent; read the request until it is final. The answer lists
the steps.

## Services a budget can pay

`GET /api/v1/budget/services` lists testnet services a budget can pay today: a short list checked by hand,
and the Superstables demo services, all x402 `exact` in test USDC: on Base Sepolia, except one service on Arc
Testnet. Each has `name`, `url` (a full example request), `method`, `price` (`{"amount": "0.01", "asset": "USDC"}`),
`network`, `chain`, `category` and `description`. `chains` describes every chain a budget works on, with its
faucets, and `?chain=arc-testnet` narrows the list to one chain. `sample: true` marks prepared sample output. The payment terms were checked; what each
service returns was not.

## Limits and errors

- Creating: 10 requests per 10 minutes per caller network (`RateLimit-Policy: "budget_create";q=10;w=600`),
  20 per agent key in the same 10 minutes, and one open request per agent and chain (`409 request_open`, naming the open one).
- Reading: 120 a minute. Cancelling: 30 a minute.
- A missing or wrong proof is `401 agent_proof`, with a `reason`: `missing`, `malformed`, `stale` (more than
  300 seconds off), `nonce`, `signature`, `agent_mismatch` (the body names another agent) or `replayed`
  (the nonce was used in the last 10 minutes).

| Status and code | Means | Do |
| --- | --- | --- |
| `400 invalid_json`, `413 body_too_large`, `415 unsupported_media_type` | The body is not a JSON object, is over 4,000 bytes, or is not sent as `application/json` | Fix the request |
| `400 unknown_parameter`, `invalid_label`, `invalid_kind`, `invalid_transaction` | A field this route does not take, or a value it cannot read | Fix the body |
| `400 unsupported_rail`, `unsupported_chain` | A rail other than `evm`, `tempo` or `solana`, a rail that does not match the agent's key, or a chain not listed above | Use a supported pair |
| `400 invalid_solana` | `solana` is not `{"amount_atomic": "<whole number>"}`, or a revoke has an amount | Fix the body |
| `400 invalid_idempotency_key` | The key is not 16 to 255 visible ASCII characters | Use a UUID |
| `401 agent_proof` | The proof is missing, malformed, stale, signed for another origin, method, path or body, names another agent, or reuses a nonce; `reason` says which | Use the signing key's address in the body and in `Superstables-Agent`, and sign the six lines with this site's origin, the exact path and body, the current time and a new nonce |
| `401 missing_token` | No bearer token on a read or a cancel | Send `Authorization: Bearer <access_token>` |
| `403 agent_not_linked` | The agent has not been added on this chain | Add it first (step 1) |
| `404 not_found` | No request with this id for this token | Check both against the answer that created it |
| `409 request_open` | This agent already has an open request on this chain; `open_request` names it | Read that one until it is final |
| `409 proof_reused`, `duplicate_request` | This signed request, or this key and body, already made a request | Read that request with its token |
| `409 already_linked` | An add-agent request with `then` for an agent already added on this chain; `owner` names the account and `owner_proof` identifies the original add-agent request | Check `owner_proof`, then ask for each transaction with `POST /api/v1/budget/approvals` |
| `409 idempotency_conflict` | Another request with this key is in progress | Retry after `Retry-After` |
| `409 wallet_asked`, `not_open` | A cancel after the owner's wallet was asked, or after the request ended | Read the request instead |
| `422` codes from step 2 | The transaction is not one the owner can be asked to send | Fix the transaction |
| `422 idempotency_key_reused`, `idempotency_key_expired` | The key was used for another body, or is over an hour old | Use a new key |
| `429 rate_limited` | A rate limit | Retry after `Retry-After` |
| `500 internal_error` | An error on Superstables' side | Retry later; quote `request_id` when you report it |

Errors are JSON with an `error` object holding `code` and `message`. Errors from the request routes also have
`retryable`, `money_moved: false`, `doc_url` and `request_id`; errors from the services list may not.
`money_moved: false` describes the API call, not whether the owner's wallet sent a transaction. New codes may
be added; treat an unknown one as information.

## Rules for agents

- Show the owner `approval.url` exactly as returned, including the part after `#`, and the match code, in your
  own message. Never approve anything yourself.
- Ask for a cap the owner chose, never an unlimited approve.
- On Solana, tell the owner to switch their Solana wallet to devnet first (in Phantom: Settings, Developer
  Settings, Testnet Mode).
- After `confirmed`, read the chain yourself before you report success.
- Treat `wallet_asked: true`, `sending`, `unknown` or any `tx_hash` as "the transaction may have been sent", never
  as "nothing was sent".
