# API reference

Desk runtime: the desk runtime 0.6.0 over the desk engine 0.7.0, the engine vendored in `engine/vendor`. Base URL for the local paper desk: `http://127.0.0.1:8787` (`DESK_HOST`, `DESK_PORT`).

## Conventions

- JSON in, JSON out. A POST or PUT must send `Content-Type: application/json` or the desk answers `415 UNSUPPORTED_MEDIA_TYPE`. Malformed JSON gets `400 BAD_JSON`.
- Size limits:
  - a write body is at most 16 KiB (`413 BODY_TOO_LARGE`);
  - a read (GET/HEAD) must carry no body (`413 BODY_NOT_ALLOWED`);
  - a URL is at most 2048 characters (`414 URI_TOO_LONG`);
  - a path must be valid percent-encoding (`400 BAD_PATH`).
- Rate limits (fixed 60 s windows):
  - per IP: 120 reads/min and 60 writes/min;
  - per agent key: 30 writes/min. Only writes that get past the signature checks count, so a spoofed key cannot spend another agent's budget.
  - Over a limit the desk answers `429 RATE_LIMITED` with a `Retry-After` header in seconds. If its bounded counter table is full it answers `503 RATE_TABLE_FULL`, also with `Retry-After`.
  - These edge limits are separate from the per-account trading limit of 6 opens per minute, which is a risk reason code.
- There are no API keys or auth headers. Every state-changing request carries an ed25519 signature by the agent key, or, for payouts, guardrails and settings, by the builder wallet key. See [signing-spec.md](signing-spec.md).
- Every signed message names the desk's **signing domain** (`GET /v1/desk` returns it as `domain`). The paper desk uses `pinch-paper` and a live desk uses a different domain, so a signature is only ever valid on one of them.
- Money is in USD as JSON numbers, rounded to 1e-6. Timestamps are Unix epoch milliseconds.
- Errors come in two shapes:
  - most endpoints return `{"error": {"code": "UPPER_SNAKE", "detail": "human text"}}`;
  - intent rejections return `{"status": "REJECTED", "intentId": "<sha256 hex>|null", "reasons": [{"code", "detail"}, ...]}`, and the HTTP status comes from the first reason.
- The desk binds to loopback unless its operator opts in explicitly. Request timeouts are 15 s for headers and 30 s for the whole request.
- **Public edge.** A public deployment splits reads and signed writes onto separate listeners behind a reverse proxy.
  - A read-only listener answers writes with `405 WRITES_NOT_EXPOSED`, and a write-only listener answers reads with `405 READS_NOT_EXPOSED`. Use the write URL the operator publishes for POSTs.
  - CORS allows only the configured site origins. A browser preflight or write from any other origin gets `403 ORIGIN_NOT_ALLOWED`. Server-side agents send no `Origin` header and are unaffected.
  - `HEAD` works on every read route.
- Account statuses: `PENDING_FEE` -> `EVALUATION` -> `PASSED` -> `QUEUED`? -> `FUNDED`. Any trading status can end in `OUT` (breached and forfeited).

## Endpoints

| method | path | signed by | success |
| --- | --- | --- | --- |
| GET | `/v1/desk` | none | 200 |
| GET | `/v1/products` | none | 200 |
| POST | `/v1/seats` | agent key | 201 |
| POST | `/v1/accounts` | agent key | 201 |
| POST | `/v1/accounts/:accountId/fee-paid` | none (paper) / on-chain payment (live) | 200 |
| POST | `/v1/accounts/:accountId/intents` | agent key | 200 `FILLED` |
| POST | `/v1/intents` | agent key | 200 `FILLED` |
| POST | `/v1/accounts/:accountId/payouts` | agent key or builder wallet key | 202 `PENDING` |
| GET | `/v1/accounts/:accountId` | none | 200 |
| GET | `/v1/accounts/:accountId/fee` | none | 200 |
| GET | `/v1/accounts/:accountId/history` | none | 200 |
| GET | `/v1/agents/:agentKey` | none | 200 |
| GET | `/v1/agents/:agentKey/guardrails` | none | 200 |
| PUT | `/v1/agents/:agentKey/guardrails` | builder wallet key | 200 `SAVED` |
| GET | `/v1/agents/:agentKey/settings` | none | 200 |
| PUT | `/v1/agents/:agentKey/settings` | builder wallet key | 200 `SAVED` |
| GET | `/v1/builders/:wallet/agents` | none | 200 |
| GET | `/v1/leaderboard` | none | 200 |
| GET | `/v1/receipts` | none | 200 |
| GET | `/healthz` | none | 200 (503 when halted) |

Any other path under `/v1/` returns `404 NOT_FOUND`. A known path called with the wrong method returns `405 METHOD_NOT_ALLOWED`.

---

### GET /healthz

Returns liveness. The status is `200` while the desk is running and `503` while it is halted.

```json
{ "ok": true, "mode": "paper", "halted": null }
```

### GET /v1/receipts

Lists executed settlement legs: builder payouts, fee refunds, buyback and burn. Each leg carries its on-chain signature at `finalized`. A paper desk never executes settlement, so the list stays empty.

```json
{ "mode": "live",
  "receipts": [ { "key": "payout:builder:acct_…:payout-1", "kind": "builder-payout", "accountId": "acct_…", "amountUsd": 128.303971, "doneAtMs": 0,
                  "legs": [ { "leg": "spl.transfer", "status": "DONE", "signature": "<base58 tx signature>", "slot": 0, "confirmationStatus": "finalized" } ] } ] }
```

### GET /v1/desk

Returns desk state, the signing domain, the latest marks, the guards and the rules summary.

```json
{
  "mode": "paper",
  "venues": { "eval": "paper", "funded": "paper" },
  "domain": "pinch-paper",
  "policyDigest": "56e50dcf…",
  "halted": null,
  "equityUsd": 100000, "equityHighUsd": 100000, "lastMarkAtMs": 1790683200000,
  "reduceOnly": false, "reduceOnlyReason": null,
  "grossNotionalUsd": 0, "grossCapUsd": 800000,
  "fundedCapitalInUseUsd": 0, "fundedCapacityUsd": 100000, "fundingQueue": [],
  "accounts": { "PENDING_FEE": 0, "EVALUATION": 1, "PASSED": 0, "QUEUED": 0, "FUNDED": 0, "OUT": 0 },
  "agents": 1,
  "restingStops": { "eval": 1, "funded": 0 },
  "fees": { "intakeCount": 1, "totalUsd": 149, "burnUsd": 74.5, "deskUsd": 74.5, "byAsset": { "USDC": { "raw": 149000000, "burnRaw": 74500000, "deskRaw": 74500000 }, "SOL": {…}, "TOKEN": {…} } },
  "payouts": { "count": 0, "grossUsd": 0, "builderUsd": 0, "deskUsd": 0 },
  "queue": { "pending": 2, "byKind": { "entry-fee-burn": { "count": 1, "amountUsd": 74.5 }, "entry-fee-desk": { "count": 1, "amountUsd": 74.5 } } },
  "lastRolledDay": "2026-09-29",
  "events": 11, "eventsRetained": 11, "compactionBaseSeq": 0,
  "marks": { "BTC": 60000, "ETH": 2000, "SOL": 100, "HYPE": 20 },
  "reconciliation": { "ok": true, "eval": { "ok": true, "diffs": [] }, "funded": { "ok": true, "diffs": [] } },
  "rules": {
    "markets": ["BTC", "ETH", "SOL", "HYPE"],
    "products": { "turbo": { "profitTargetFraction": 0.09, "dailyLossFraction": 0.03, "staticDrawdownFraction": 0.03, "maxLeverage": 20 }, "pro": {…}, "classic": {…} },
    "accountSizesUsd": [5000, 10000, 25000, 50000],
    "minOrderNotionalUsd": 11, "dailyResetUtcMinute": 30, "perBuilderCapUsd": 100000,
    "fundedCapacity": "…", "riskSizing": "…", "exchangeSideStops": true,
    "intentMaxTtlMs": 120000, "rateLimit": { "maxOpensPerWindow": 6, "windowMs": 60000 },
    "minimumPayoutBuilderUsd": 50
  }
}
```

The fields agents use:

- `domain` is what every signature is bound to.
- `marks` are the mids the desk last marked at, the same prices its risk checks use. They refresh on every intent and every risk tick (about every 5 s). The source is Hyperliquid's public `allMids`.
- If `halted` is non-null, the desk has stopped after an ambiguous venue outcome. Stop trading until it clears.
- While `reduceOnly` is true, funded opens are refused; closes still work.

### GET /v1/products

Returns the catalog: every product x size with its fee, profit target and limits.

```json
{
  "mode": "paper",
  "products": [
    { "product": "turbo", "profitTargetFraction": 0.09, "dailyLossFraction": 0.03, "staticDrawdownFraction": 0.03, "maxLeverage": 20,
      "sizes": [ { "sizeUsd": 5000, "feeUsd": 39, "feeWithUpgrade90Usd": 46.8, "profitTargetUsd": 5450, "dailyLossLimitUsd": 150, "staticFloorUsd": 4850, "fundedCapitalRequiredUsd": 300 }, … ] },
    { "product": "pro", … }, { "product": "classic", … }
  ],
  "feeTableStatus": "launchDefault",
  "split": { "standard": "80/20", "upgraded": "90/10", "upgradeSurchargeBps": 2000 },
  "payWith": { "SOL": true, "USDC": true, "TOKEN": false, "tokenDiscountBps": 2000 },
  "feeRouting": { "cash": { "burnBps": 5000, "deskBps": 5000 }, "token": { "burnBps": 5000, "reserveBps": 5000 } },
  "payouts": { "minimumBuilderUsd": 50, "payable": "min(realized above start, equity at mark above start)" },
  "dailyResetUtc": "00:30",
  "perBuilderCapUsd": 100000,
  "economics": [ { "product": "turbo", "sizeUsd": 5000, "feeUsd": 39, "…": "breakeven, margin and token-reserve coverage" }, … ]
}
```

### POST /v1/seats

Binds an agent key to a builder payout wallet. The call is idempotent when repeated with the same wallet. A different wallet is refused, because the wallet is bound to the key.

Request (signature over the seat bytes, see [signing-spec.md](signing-spec.md#seat-registration)):

```json
{ "agentKey": "<base58 32B>", "builderWallet": "<base58 32B Solana address>", "signature": "<base58 64B>" }
```

Response `201`:

```json
{ "agentKey": "7n9A…rbsM", "builderWallet": "62uZ…1YQG", "registeredAtMs": 1790683200000, "accounts": [] }
```

| HTTP | code |
| --- | --- |
| 400 | `BAD_REQUEST` (body is not an object) |
| 401 | `SEAT_SIGNATURE_INVALID` (bad signature, wrong domain, or a malformed key) |
| 409 | `AGENT_KEY_INVALID`, `BUILDER_WALLET_INVALID`, `AGENT_WALLET_MISMATCH` (already bound to another wallet) |

### POST /v1/accounts

Checkout: buys one evaluation. Creates a `PENDING_FEE` account and returns the fee payment instruction. The account id is derived from `(domain, agentKey, checkout nonce)`, so re-posting an identical checkout is idempotent: it returns the same account with `duplicate: true`, even after the original expiry.

Request:

```json
{
  "checkout": { "version": 1, "agentKey": "<base58>", "product": "pro", "sizeUsd": 10000, "upgrade90": false, "payWith": "USDC", "nonce": "mdzk3f0g-9a8b7c6d5e4f3a2b", "expiresAt": 1790683260000 },
  "signature": "<base58 64B>"
}
```

- `product` is one of `turbo`, `pro`, `classic`.
- `sizeUsd` must be one of `accountSizesUsd`.
- `payWith` is `SOL`, `USDC` or `TOKEN`. `TOKEN` is disabled until the mint is bound.
- `upgrade90` buys the 90/10 split for a +20% fee.

Response `201` (paper):

```json
{
  "accountId": "acct_38c373d827a9703015d3",
  "duplicate": false,
  "account": { "…": "account view, status PENDING_FEE (see GET /v1/accounts/:id)" },
  "fee": {
    "required": true, "usd": 149, "asset": "USDC", "usdPerUnit": 1, "amountRaw": 149000000,
    "payTo": null, "memo": "acct_38c373d827a9703015d3",
    "instructions": "PAPER MODE: no payment. POST /v1/accounts/:accountId/fee-paid records a simulated receipt."
  }
}
```

On a live desk with fee intake configured, `fee` is a time-limited quote:

```json
{ "required": true, "accountId": "acct_…", "asset": "USDC", "mint": "EPjF…", "decimals": 6, "usd": 149, "usdPerUnit": 1, "amountRaw": 149000000,
  "priceSource": "…", "priceObservedAtMs": 0, "quotedAtMs": 0, "expiresAtMs": 0, "payTo": "<intake address>", "memo": "acct_…",
  "instructions": "Send at least amountRaw of USDC (mint …) to payTo with an SPL memo equal to the account id before expiresAtMs; once finalized, POST /v1/accounts/:accountId/fee-paid {\"signature\": \"<tx signature>\"}." }
```

If the price is unavailable, the quote is `{required, usd, asset, error: "PRICE_UNAVAILABLE"|"PRICE_STALE"|…, detail}`. The payment is made by the builder's or agent's own wallet; the desk and this SDK never move funds.

| HTTP | code |
| --- | --- |
| 400 | `BAD_REQUEST` |
| 401 | `SIGNATURE_INVALID` |
| 404 | `AGENT_NOT_REGISTERED` (call `POST /v1/seats` first) |
| 409 | `PENDING_CHECKOUT_LIMIT` (3 unpaid checkouts per agent), `BUILDER_CAP` (the builder's live accounts would exceed $100K combined) |
| 422 | `SCHEMA_INVALID` (including a reused nonce with different terms), `EXPIRED`, `EXPIRY_TOO_FAR`, `PRODUCT_NOT_OFFERED`, `TOKEN_PAY_DISABLED`, `CAPACITY` (the desk already has `accounts.maxOpenEvaluations` open evaluations, 1,000 by default) |

### POST /v1/accounts/:accountId/fee-paid

- **Paper desk:** the body is `{}`. The desk records a simulated receipt in the checkout asset (USDC at 1, SOL at the SOL mid) and the account starts its `EVALUATION` at the starting balance. The call is idempotent and a repeat returns `duplicate: true`.
- **Live desk:** the body is `{"signature": "<finalized transaction signature>"}`. The desk verifies the payment read-only at `finalized`: memo equals the account id, amount is at least the quote, the payment landed inside the quote window, and each signature is used once. On a live desk the paper stub is refused with `403 FEE_VERIFICATION_UNAVAILABLE`.
- **Refunds (live):** sometimes a verified payment arrives but intake refuses it, for `BUILDER_CAP`, `CAPACITY`, a second payment for an already-paid account (`ACCOUNT_NOT_PENDING`), or an underpayment (`FEE_UNDERPAID`). The desk then queues **one refund** to the single payer, in the same asset minus the network fee (10,000 lamports or 0.01 USDC), and answers `409 FEE_REFUSED_REFUND_QUEUED`. The refund appears in `GET /v1/receipts` once it executes.
  - If the payment has several payers, the refund is held for operator review.
  - A payment that lands outside the quote window is refused **without** an automatic refund (`OUTSIDE_QUOTE_WINDOW`); the operator handles it.
  - Re-posting a refused signature returns `SIGNATURE_ALREADY_USED`.

Response `200`:

```json
{ "accountId": "acct_38c3…", "status": "EVALUATION", "receiptId": "paper-fee:acct_38c3…", "asset": "USDC", "amountRaw": 149000000, "usdPerUnit": 1, "paidUsd": 149, "duplicate": false }
```

| HTTP | code |
| --- | --- |
| 400 | `BAD_REQUEST` (live: missing `signature`) |
| 403 | `FEE_VERIFICATION_UNAVAILABLE`, `TOKEN_PAY_DISABLED` |
| 404 | `UNKNOWN_ACCOUNT` |
| 409 | `ACCOUNT_NOT_PENDING`, `ASSET_MISMATCH`, `FEE_UNDERPAID`, `BUILDER_CAP`, `CAPACITY`, `RECEIPT_INVALID`, `RECEIPT_REFUNDED`, `SIGNATURE_ALREADY_USED`, `FEE_REFUSED_REFUND_QUEUED` (live: refused and refund queued) |
| 422 | live verification: `SIGNATURE_INVALID`, `QUOTE_MISSING`, `TX_NOT_FOUND`, `TX_NOT_FINALIZED`, `TX_FAILED`, `TX_MISMATCH`, `MEMO_MISMATCH`, `NO_TRANSFER_TO_INTAKE`, `UNDERPAID`, `OUTSIDE_QUOTE_WINDOW`, `PRICE_UNAVAILABLE`, `PRICE_STALE` |
| 503 | `MARKS_UNAVAILABLE` (paper SOL pricing), `RPC_ERROR` |

### POST /v1/accounts/:accountId/intents (and POST /v1/intents)

Submits a signed trade intent. `/v1/intents` takes the account id from the signed intent. The route version requires the two to match (`ACCOUNT_ROUTE_MISMATCH`).

The pipeline:

1. **Admission:** schema, signature, expiry window and the nonce replay guard.
2. **Risk:** every check against fresh marks.
3. **Execution:** one market IOC order on the account's venue (the paper evaluation venue, or the funded venue).
4. **Exchange-side stop:** a trigger stop-market is placed or replaced for the account's exact resulting size.
5. **Ledger:** the fill is applied to the account.

Open request (the stop is required):

```json
{
  "intent": { "version": 2, "agentKey": "<base58>", "accountId": "acct_…", "nonce": "n-1", "expiresAt": 1790683260000,
              "action": "open", "coin": "SOL", "side": "long", "notionalUsd": 2000, "leverage": 2, "stopPrice": 98, "takeProfit": 110 },
  "signature": "<base58 64B>"
}
```

Close request:

```json
{ "intent": { "version": 2, "agentKey": "…", "accountId": "acct_…", "nonce": "n-2", "expiresAt": 1790683260000,
              "action": "close", "coin": "SOL", "side": "long", "closeFraction": 1 },
  "signature": "…" }
```

Field rules:

- `coin` is one of `BTC`, `ETH`, `SOL`, `HYPE`.
- `side` is the direction being opened, or the direction of the position being closed.
- `notionalUsd` is converted to size at the mark and floored to the market's size decimals (`szDecimals`: BTC 5, ETH 4, SOL 2, HYPE 2). The floored notional must be at least $11.
- `leverage` must be at or below min(product cap, coin cap): products allow 20x, coins BTC 20x, ETH 20x, SOL 10x, HYPE 5x (`LEVERAGE_EXCEEDS_CAP`). Buying power is `sizeUsd x 20` of gross notional, whatever `leverage` you declare, and one coin's notional is capped at `sizeUsd x coin cap` (`ASSET_LEVERAGE_EXCEEDED`).
- `stopPrice` must sit on the loss side of the mark: below it for a long, above it for a short. The stop risk must fit the loss budget (see [rules.md](rules.md#risk-based-sizing)).
- `takeProfit` is optional and validated (above the mark for a long, below it for a short). The desk records it but does not place a take-profit order; close the position yourself.
- `closeFraction` is in (0, 1]. A partial close must be at least $11 notional; `1` closes everything.
- Adding to a position in the same direction is allowed. The new stop replaces the old one for the whole position. Opening the opposite direction while a position is open is refused (`OPPOSITE_POSITION_OPEN`).

Response `200` `FILLED`:

```json
{
  "status": "FILLED",
  "intentId": "8caef258…",
  "order": { "accountId": "acct_…", "venue": "eval", "coin": "SOL", "orderType": "market", "markPx": 100, "action": "open", "isBuy": true, "size": 20, "reduceOnly": false, "notionalUsd": 2000, "stopPrice": 98, "stopRiskUsd": 40, "lossBudgetUsd": 300, "…": "…" },
  "fill": { "oid": 1, "avgPx": 100.02, "totalSz": 20, "fee": 0.90018, "coin": "SOL", "isBuy": true, "venueReduceOnly": false, "venue": "eval" },
  "stop": { "oid": 2, "triggerPx": 98, "size": 20, "isBuy": false },
  "evaluation": { "passed": false, "accountStatus": "EVALUATION" },
  "account": { "…": "account view after the fill" }
}
```

- `stop` is `null` when the account ends flat, and also on the fill that passes an evaluation.
- `evaluation` is `null` on the funded venue. `{"passed": true, "accountStatus": "FUNDED"}` (or `"QUEUED"`) means this fill passed the evaluation.

Rejection (HTTP status from the first reason; read every reason):

```json
{ "status": "REJECTED", "intentId": "17d42d5d…",
  "reasons": [ { "code": "RISK_BUDGET_EXCEEDED", "detail": "stop risk 4200 (420 SOL x |100 - 90|) > remaining loss budget 298.69982 (daily 298.69982, static 498.69982, other positions' stop risk 0); max notional 986.9982" } ] }
```

`intentId` is `null` when the intent failed admission, in which case its nonce was not consumed. When `intentId` is set, the nonce is spent, and a resubmission of the same envelope returns `REPLAYED`.

Every rejection with a non-null `intentId` for one of the signer's own accounts is recorded in that account's history (`GET /v1/accounts/:id/history`, event `intent_rejected` with the reason codes; never the signature). Builder guardrails add `GUARDRAIL_PAUSED`, `GUARDRAIL_MARKET`, `GUARDRAIL_LEVERAGE`, `GUARDRAIL_RISK` and `GUARDRAIL_DAILY_STOP` on **opens only**; closes are never blocked by them.

| HTTP | codes |
| --- | --- |
| 400 | `SCHEMA_INVALID`, `AGENT_KEY_INVALID`, `ACCOUNT_ROUTE_MISMATCH` |
| 401 | `SIGNATURE_INVALID` |
| 403 | `ACCOUNT_AGENT_MISMATCH` |
| 404 | `ACCOUNT_UNKNOWN` |
| 409 | `REPLAYED` |
| 429 | `RATE_LIMITED` (when it is the first reason) |
| 422 | `EXPIRED`, `EXPIRY_TOO_FAR`, `REPLAY_GUARD_SATURATED`, and every other risk code: `ACCOUNT_NOT_TRADING`, `ACCOUNT_FLATTENING`, `MARKET_NOT_ALLOWED`, `MARK_UNAVAILABLE`, `DESK_REDUCE_ONLY`, `LEVERAGE_EXCEEDS_CAP`, `SIZE_ROUNDS_TO_ZERO`, `NOTIONAL_BELOW_MINIMUM`, `OPPOSITE_POSITION_OPEN`, `BUYING_POWER_EXCEEDED`, `ASSET_LEVERAGE_EXCEEDED`, `DESK_GROSS_CAP_EXCEEDED`, `STOP_INVALID`, `RISK_BUDGET_EXCEEDED`, `TAKE_PROFIT_INVALID`, `POSITION_NOT_FOUND`, `SIDE_MISMATCH`, `CLOSE_BELOW_MINIMUM`, `GUARDRAIL_PAUSED`, `GUARDRAIL_MARKET`, `GUARDRAIL_LEVERAGE`, `GUARDRAIL_RISK`, `GUARDRAIL_DAILY_STOP` |
| 502 | `VENUE_REJECTED` |
| 503 | `RUNTIME_HALTED`, `MARKS_UNAVAILABLE`, `VENUE_AMBIGUOUS`, `FILL_UNAPPLIED` |

### POST /v1/accounts/:accountId/payouts

Requests an on-demand payout from a `FUNDED` account. The request must be signed by the account's agent key or by its builder wallet key.

- Payable amount: `min(balance - start, equity at mark - start)`, floored at 0. Only realized profit is paid, and a payout can never take equity at mark below the start.
- The payable amount is split at the account's rate (80/20, or 90/10 with the upgrade). The builder share must be at least $50.
- The payout comes out of the account balance. The static floor does not move.
- The payout is queued as `PENDING`. A paper desk never executes it. A live desk executes it only behind the live settlement gate: an SPL USDC transfer from the desk treasury to the builder wallet's token account, which is created if missing, confirmed at `finalized`, and listed in `GET /v1/receipts`.
- A builder can sign from a browser wallet: the signature is plain ed25519 over the canonical payout bytes, which is what `signMessage(bytes)` returns (see [signing-spec.md](signing-spec.md#signing-in-a-browser-wallet-phantom-solflare-backpack)).
- **`requestId` is single-use forever** on each account. Re-sending the same signed request inside its expiry window returns `REQUEST_REPLAYED`. Reusing the id at any later time returns `REQUEST_ID_REUSED`.

Request:

```json
{ "request": { "version": 1, "accountId": "acct_…", "requestId": "payout-1", "signer": "<agent key or builder wallet>", "expiresAt": 1790683260000 },
  "signature": "<base58 64B>" }
```

Response `202`:

```json
{
  "status": "PENDING", "accountId": "acct_…", "requestId": "payout-1", "signerRole": "agent",
  "payableUsd": 351.2, "builderUsd": 280.96, "deskUsd": 70.24, "builderSplitBps": 8000,
  "destination": "<builder wallet>",
  "queue": [ { "key": "payout:builder:acct_…:payout-1", "kind": "builder-payout", "amountUsd": 280.96, "asset": "USDC", "status": "PENDING", "mode": "paper" },
             { "key": "payout:desk:acct_…:payout-1", "kind": "desk-buyback-burn", "amountUsd": 70.24, "asset": "USDC", "status": "PENDING", "mode": "paper" } ],
  "execution": "PAPER: queued as evidence, never executed",
  "account": { "…": "account view after the payout" }
}
```

| HTTP | codes |
| --- | --- |
| 400 | `BAD_REQUEST`, `ACCOUNT_ROUTE_MISMATCH` |
| 401 | `SIGNATURE_INVALID`, `SIGNER_NOT_AUTHORIZED` (neither the agent nor the builder key) |
| 404 | `UNKNOWN_ACCOUNT` |
| 409 | `REQUEST_REPLAYED` (same request inside its window), `REQUEST_ID_REUSED` (the id was used before), `PAYOUT_HISTORY_FULL`, `PAYOUT_NOT_RECORDED` |
| 422 | `SCHEMA_INVALID`, `EXPIRED`, `EXPIRY_TOO_FAR`, `ACCOUNT_NOT_FUNDED`, `NOTHING_PAYABLE`, `PAYOUT_BELOW_MINIMUM` |
| 503 | `RUNTIME_HALTED`, `MARKS_UNAVAILABLE`, `MARK_UNAVAILABLE` |

### GET /v1/accounts/:accountId/fee

The account's fee payment instruction, readable without a signature so a builder's browser can pay the fee. It never issues a new quote.

```json
{ "accountId": "acct_…", "status": "PENDING_FEE", "paid": false, "feeUsd": 149, "payWith": "USDC", "receiptId": null, "paidAtMs": null,
  "quote": { "accountId": "acct_…", "asset": "USDC", "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", "decimals": 6, "usd": 149, "usdPerUnit": 1, "amountRaw": 149000000,
             "priceSource": "usdc-peg-1.00", "priceObservedAtMs": 0, "quotedAtMs": 0, "expiresAtMs": 0, "payTo": "<fee intake address>", "memo": "acct_…", "expired": false } }
```

- **Live desk:** `quote` is the quote stored at checkout, field for field (`asset`, `mint`, `decimals`, `usd`, `usdPerUnit`, `amountRaw`, `priceSource`, `priceObservedAtMs`, `quotedAtMs`, `expiresAtMs`, `payTo`, `memo`) plus `expired`. An expired quote is returned as stored with `expired: true`: paying it gets `OUTSIDE_QUOTE_WINDOW`, so re-post the signed checkout to issue a fresh quote first. With no stored quote, `quote` is `null` and `detail` says so.
- **Paper desk:** `quote` is `{"paper": true, "usd", "asset", "usdPerUnit", "amountRaw", "memo", "payTo": null, "instructions"}`. For SOL, `amountRaw` uses the SOL mid the desk last marked at (`null` before the first mark).
- Once the fee is recorded (`paid: true`), `quote` is `null`.
- **What the live verifier accepts** (`POST /v1/accounts/:id/fee-paid {"signature"}`): a finalized, successful transaction whose first signature is the one submitted, never used before, with block time inside `[quotedAtMs - 60 s, expiresAtMs + 60 s]`, and:
  - exactly **one top-level SPL Memo** instruction (Memo v2 `MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr`, or v1 `Memo1UhkJRfHyvLMcVucJwxXeuD728EqVDDwQDxFMNo`) whose text is exactly the account id (`memo`);
  - **SOL:** top-level System Program `transfer` instructions to `payTo` summing to at least `amountRaw` lamports (the `payTo` balance must rise by at least that much);
  - **USDC:** top-level SPL Token (or Token-2022) `transferChecked` (mint = `mint`, decimals 6) or `transfer` instructions into a token account **owned by `payTo`** for that mint (normally `payTo`'s associated token account) summing to at least `amountRaw`; the owner's balance of that mint must rise by at least that much. If that token account does not exist, the payer's transaction must create it (`createAssociatedTokenAccountIdempotent` before the transfer).
  - Transfers made inside another program (CPI) do not count. Less than `amountRaw` is refused and refunded (`FEE_REFUSED_REFUND_QUEUED`).

Errors: `404 UNKNOWN_ACCOUNT`.

### GET /v1/accounts/:accountId/history

Chart and activity feed for one account, both phases (evaluation and funded). Public read. Query (all optional, non-negative integers; anything else is `400 BAD_QUERY`):

| param | default | meaning |
| --- | --- | --- |
| `fromMs` | the account's creation time | window start |
| `toMs` | now | window end |
| `bucketMs` | `300000` (5 min) | point grid; rounded up to whole minutes; widened so the window has at most **2,000** points (the response says which `bucketMs` was used) |
| `limit` | `500` | max fills and max events returned (1..1000): the **newest** `limit` in the window, oldest first |

```json
{
  "accountId": "acct_…", "status": "FUNDED", "phase": "FUNDED",
  "fromMs": 1790683200000, "toMs": 1790690400000, "bucketMs": 300000,
  "points": [ { "t": 1790683200000, "equityUsd": 10000, "balanceUsd": 10000, "dayStartEquityUsd": 10000, "phase": "EVALUATION", "status": "EVALUATION" }, … ],
  "fills": [ { "id": 7, "t": 1790683201000, "coin": "SOL", "side": "buy", "positionSide": "long", "px": 100.02, "sz": 300, "sizeUsd": 30006, "feeUsd": 13.5027,
               "kind": "open", "pnlUsd": 0, "oid": 1, "phase": "EVALUATION", "venue": "eval", "intentId": "8caef258…" }, … ],
  "events": [ { "id": 3, "t": 1790683201000, "kind": "intent_rejected", "reason": "RISK_BUDGET_EXCEEDED", "codes": ["RISK_BUDGET_EXCEEDED"], "intentId": "…", "action": "open", "coin": "SOL", "side": "long", "text": "RISK_BUDGET_EXCEEDED: stop risk …" }, … ],
  "limit": 500,
  "truncated": { "fills": false, "events": false }
}
```

- **points:** a grid aligned to `bucketMs` (`t` = bucket start). Each value is the last observation at or before the end of that bucket, carried forward through quiet buckets (a flat account's equity is its balance). Observations are taken at most once a minute: at risk marks while positions are open (about every 5 s, kept per minute), fills that leave the account flat, daily resets, payouts and status changes. `phase` shows where the equity scale restarts (a funded account starts at its starting balance).
- **fills:** `kind` is `open`, `close` (agent intent), `stop` (exchange-side stop) or `forced` (risk loop: breach, evaluation pass, unprotected position). `side` is the order side (`buy`/`sell`), `positionSide` the direction of the position opened or reduced. `pnlUsd` is the gross realized P&L of the fill (0 for opens); fees are in `feeUsd`. `intentId` is set for intent fills.
- **events** `kind`: `evaluation_started`, `intent_rejected` (`reason` = first code, `codes` = all), `daily_reset` (`dayStartEquityUsd`), `passed`, `queued`, `funded`, `out` (`reason` = `DAILY_LOSS`, `STATIC_DRAWDOWN` or both), `payout` (`grossUsd`, `builderUsd`, `deskUsd`, `signerRole`), `flatten_requested`, `guardrails_updated`, `settings_updated`. Every event has a human `text`.
- `truncated.fills` / `truncated.events` true means older items in the window were cut: page back with `toMs` = the oldest `t` you received (items at exactly that ms can repeat; de-duplicate by `id`).
- The history is written in the same transaction as the desk event that produced it and is kept when the event log is compacted.

Errors: `400 BAD_QUERY`, `404 UNKNOWN_ACCOUNT`.

### GET /v1/agents/:agentKey/guardrails

The builder's guardrails for the agent. `guardrails` is always filled in: with no builder guardrails (`set: false`) it shows the desk rules. Agents should read this every loop and not send opens that break it.

```json
{ "agentKey": "F52k…", "builderWallet": "7aQG…", "set": true, "updatedAtMs": 1790683260000,
  "guardrails": { "paused": false, "markets": ["BTC", "SOL"], "maxLeverage": 3, "maxRiskPerTradePctOfBudget": 50, "dailyStopPctOfLimit": 80 },
  "deskLimits": { "markets": ["BTC", "ETH", "HYPE", "SOL"], "maxLeverage": 20, "maxRiskPerTradePctOfBudget": 100, "dailyStopPctOfLimit": 100 },
  "enforcement": "desk-enforced on OPEN intents only (…); closes are never blocked; …" }
```

Enforced on every **open** of every account of the agent:

| guardrail | open refused when | code |
| --- | --- | --- |
| `paused` | true | `GUARDRAIL_PAUSED` |
| `markets` | the coin is not listed | `GUARDRAIL_MARKET` |
| `maxLeverage` | `intent.leverage > maxLeverage` | `GUARDRAIL_LEVERAGE` |
| `maxRiskPerTradePctOfBudget` | stop risk of the resulting position > pct% of `lossBudget.availableUsd` (100 = the desk rule) | `GUARDRAIL_RISK` |
| `dailyStopPctOfLimit` | today's loss at mark (`dayStartEquity - equity`) >= pct% of the daily loss limit; resets at 00:30 UTC | `GUARDRAIL_DAILY_STOP` |

Errors: `404 UNKNOWN_AGENT`.

### PUT /v1/agents/:agentKey/guardrails

Set the guardrails. Signed by the agent's **bound builder wallet** (the wallet registered with `POST /v1/seats`); the agent key cannot set its own guardrails. The whole set is replaced. Values may only tighten the desk rules. Bytes: [signing-spec.md](signing-spec.md#builder-guardrails-version-1).

```json
{ "guardrails": { "version": 1, "agentKey": "F52k…", "signer": "7aQG…", "nonce": "g-1", "expiresAt": 1790683260000,
                  "paused": false, "markets": ["BTC", "SOL"], "maxLeverage": 3, "maxRiskPerTradePctOfBudget": 50, "dailyStopPctOfLimit": 80 },
  "signature": "<base58 64B>" }
```

Response `200`: `{"status": "SAVED", …the GET view…}`.

| HTTP | code |
| --- | --- |
| 400 | `BAD_REQUEST`, `AGENT_ROUTE_MISMATCH` (`guardrails.agentKey` differs from the path), `SCHEMA_INVALID` (e.g. unsorted `markets`) |
| 401 | `SIGNATURE_INVALID`, `SIGNER_NOT_AUTHORIZED` (signer is not the agent's builder wallet) |
| 404 | `AGENT_NOT_REGISTERED` |
| 409 | `REQUEST_REPLAYED` (nonce already used and unexpired), `BUILDER_NONCES_SATURATED` |
| 422 | `EXPIRED`, `EXPIRY_TOO_FAR`, `GUARDRAIL_EXCEEDS_DESK_RULES` (a market off the whitelist, or leverage above the desk cap) |

### GET /v1/agents/:agentKey/settings

Strategy settings from the builder. **Not enforced by the desk**: the agent reads them each loop and follows them. `settings` is `null` until the builder saves some.

```json
{ "agentKey": "F52k…", "builderWallet": "7aQG…", "set": true, "updatedAtMs": 1790683260000,
  "settings": { "style": "trend", "stopDistancePct": 1.5, "takeProfitR": 2, "notes": "SOL first." },
  "enforcement": "not enforced: the agent reads and follows these each loop" }
```

- `style`: the builder's chosen approach (`trend`, `revert`, `breakout`, or another short id).
- `stopDistancePct`: place stops this % from the entry.
- `takeProfitR`: close winners at this multiple of the risk taken.
- `notes`: free text from the builder, at most 500 characters.

Errors: `404 UNKNOWN_AGENT`.

### PUT /v1/agents/:agentKey/settings

Same signing and error codes as `PUT …/guardrails` (without `GUARDRAIL_EXCEEDS_DESK_RULES`):

```json
{ "settings": { "version": 1, "agentKey": "F52k…", "signer": "7aQG…", "nonce": "s-1", "expiresAt": 1790683260000,
                "style": "trend", "stopDistancePct": 1.5, "takeProfitR": 2, "notes": "SOL first." },
  "signature": "<base58 64B>" }
```

### GET /v1/builders/:wallet/agents

Every agent bound to a builder wallet (at most 100, oldest registration first), each with the same shape as `GET /v1/agents/:agentKey`.

```json
{ "builderWallet": "7aQG…", "total": 1, "truncated": false,
  "agents": [ { "agentKey": "F52k…", "builderWallet": "7aQG…", "registeredAtMs": 1790683200000,
                "accounts": [ { "accountId": "acct_…", "product": "pro", "sizeUsd": 10000, "status": "FUNDED", "phase": "FUNDED", "startingBalanceUsd": 10000,
                                "balanceUsd": 10000, "equityUsd": 10000, "payoutsUsd": 128.3, "outReason": null, "createdAtMs": 1790683200000, "fundedAtMs": 1790683265000 } ],
                "guardrails": { "set": true, "updatedAtMs": 1790683260000, "values": { "paused": false, "markets": ["BTC", "SOL"], "…": "…" } },
                "settings": { "set": false, "updatedAtMs": null, "values": null } } ] }
```

Errors: `400 WALLET_INVALID` (not base58 of 32 bytes). An unknown wallet returns `agents: []`.

### GET /v1/accounts/:accountId

Returns the public account view. Funded accounts also include `fills`, their funded-phase fills (intents, stops, forced closes). Evaluation fills are in the history (`GET /v1/accounts/:accountId/history`).

```json
{
  "accountId": "acct_38c373d827a9703015d3", "agentKey": "7n9A…", "builderWallet": "62uZ…",
  "product": "pro", "sizeUsd": 10000, "status": "EVALUATION", "phase": "EVALUATION", "venue": "eval",
  "split": { "builderBps": 8000, "deskBps": 2000, "upgrade90": false },
  "fee": { "usd": 149, "payWith": "USDC", "receiptId": "paper-fee:acct_…", "paidAtMs": 1790683200000 },
  "rules": { "profitTargetUsd": 11200, "dailyLossLimitUsd": 300, "staticFloorUsd": 9500, "maxLeverage": 20 },
  "startingBalanceUsd": 10000, "balanceUsd": 9999.09982, "equityUsd": 9998.69982, "unrealizedPnlUsd": -0.4,
  "pnl": { "netRealizedUsd": -0.90018, "atMarkUsd": -1.30018, "feesUsd": 0.90018 },
  "positions": [ { "coin": "SOL", "size": 20, "avgEntryPx": 100.02, "stopPx": 98, "markPx": 100, "unrealizedPnlUsd": -0.4 } ],
  "stops": [ { "coin": "SOL", "oid": 2, "triggerPx": 98, "size": 20, "isBuy": false, "placedAtMs": 1790683201000 } ],
  "limits": { "reasons": [], "equityUsd": 9998.69982, "dayStartEquityUsd": 10000, "dailyLossUsd": 1.30018, "dailyLossLimitUsd": 300, "staticFloorUsd": 9500, "staticHeadroomUsd": 498.69982, "…": "…" },
  "lossBudget": { "equityUsd": 9998.69982, "dailyRemainingUsd": 298.69982, "staticRemainingUsd": 498.69982, "remainingUsd": 298.69982, "openStopRiskUsd": 40, "availableUsd": 258.69982 },
  "evaluationProgress": { "targetUsd": 11200, "equityUsd": 9998.69982, "remainingUsd": 1201.30018 },
  "evaluation": null,
  "payouts": { "count": 0, "grossUsd": 0, "builderUsd": 0, "deskUsd": 0, "payableNow": null },
  "closedTrades": 0,
  "rateLimit": { "opensInWindow": 1, "maxOpensPerWindow": 6, "windowMs": 60000 },
  "outReason": null, "outAtMs": null, "createdAtMs": 1790683200000, "passedAtMs": null, "queuedAtMs": null, "fundedAtMs": null
}
```

- `positions[].size` is signed: greater than 0 is long, less than 0 is short.
- `lossBudget.availableUsd` is what a new stop can still risk.
- `payouts.payableNow` (FUNDED only) is `{realizedAboveStartUsd, equityAboveStartUsd, payableUsd, split: {builderUsd, deskUsd}, meetsMinimum}`.
- `outReason` is `DAILY_LOSS`, `STATIC_DRAWDOWN` or `DAILY_LOSS+STATIC_DRAWDOWN`.

Errors: `404 UNKNOWN_ACCOUNT`.

### GET /v1/agents/:agentKey

```json
{ "agentKey": "7n9A…", "builderWallet": "62uZ…", "registeredAtMs": 1790683200000,
  "accounts": [ { "accountId": "acct_…", "product": "pro", "sizeUsd": 10000, "status": "EVALUATION", "phase": "EVALUATION", "startingBalanceUsd": 10000, "balanceUsd": 9999.09982,
                  "equityUsd": 9998.69982, "payoutsUsd": 0, "outReason": null, "createdAtMs": 1790683200000, "fundedAtMs": null } ],
  "guardrails": { "set": false, "updatedAtMs": null, "values": { "paused": false, "markets": ["BTC", "ETH", "HYPE", "SOL"], "maxLeverage": 20, "maxRiskPerTradePctOfBudget": 100, "dailyStopPctOfLimit": 100 } },
  "settings": { "set": false, "updatedAtMs": null, "values": null } }
```

Errors: `404 UNKNOWN_AGENT`.

### GET /v1/leaderboard

Agents with at least one paid account, ranked by lifetime builder payouts, then funded accounts, then funded net realized PnL.

```json
{ "mode": "paper", "rankedBy": "lifetime builder payouts",
  "agents": [ { "rank": 1, "agentKey": "…", "builderWallet": "…", "lifetimePayoutsUsd": 280.96, "grossPayoutsUsd": 351.2, "payoutCount": 1,
                "fundedAccounts": 1, "fundedCapitalUsd": 10000, "accounts": 2, "passedEvaluations": 1, "fundedNetRealizedUsd": 351.2 } ] }
```

---

## Reason codes

The "action" column is the SDK's `PinchError.action` (see [error-handling.md](error-handling.md)): **fix** means change the request and sign a new one; **later** means transient, back off and sign a new one; **check** means read state first; **stop** means do not retry.

### Admission (intents, checkouts, payouts)

| code | HTTP | meaning | action |
| --- | --- | --- | --- |
| `SCHEMA_INVALID` | 400/422 | Malformed message: an unknown field, a missing stop, an exponent-form number, a bad nonce, or a checkout nonce reused with different terms | fix |
| `AGENT_KEY_INVALID` | 400/409 | `agentKey` is not canonical base58 of 32 bytes, or not an ed25519 point | fix |
| `SIGNATURE_INVALID` | 401/422 | Signature does not verify: wrong key, wrong domain, tampered field, or bad encoding | fix |
| `EXPIRED` | 422 | `expiresAt <= now` on the desk clock | later (new nonce; check clock skew) |
| `EXPIRY_TOO_FAR` | 422 | `expiresAt - now > 120000` ms | fix |
| `REPLAYED` | 409 | This agent already used the nonce: the envelope was already processed | check |
| `REPLAY_GUARD_SATURATED` | 422 | Too many live nonces desk-wide | later |

### Routing and runtime

| code | HTTP | meaning | action |
| --- | --- | --- | --- |
| `ACCOUNT_ROUTE_MISMATCH` | 400 | The URL account id differs from the signed one | fix |
| `AGENT_ROUTE_MISMATCH` | 400 | The URL agent key differs from the signed guardrails/settings `agentKey` | fix |
| `BAD_QUERY` | 400 | A history query parameter is not a non-negative integer, `fromMs > toMs`, or `limit` is outside 1..1000 | fix |
| `WALLET_INVALID` | 400 | The builder wallet in the path is not base58 of 32 bytes | fix |
| `ACCOUNT_UNKNOWN` | 404 | No such account (intent path) | fix |
| `UNKNOWN_ACCOUNT` / `UNKNOWN_AGENT` | 404 | No such account or agent (read and fee paths) | fix |
| `ACCOUNT_AGENT_MISMATCH` | 403 | The account belongs to another agent key | stop |
| `RUNTIME_HALTED` | 503 | The desk halted on an ambiguous venue outcome and needs operator reconciliation | stop |
| `MARKS_UNAVAILABLE` | 503 | No fresh mids from Hyperliquid | later |
| `VENUE_REJECTED` | 502 | The venue refused the order; nothing filled | later |
| `VENUE_AMBIGUOUS` | 503 | Order outcome unknown; the desk halts | check |
| `FILL_UNAPPLIED` | 503 | A fill could not be applied to the ledger; the desk halts | check |

### Risk (opens and closes)

| code | meaning | action |
| --- | --- | --- |
| `ACCOUNT_NOT_TRADING` | Status is not `EVALUATION` or `FUNDED` (`PENDING_FEE`, `PASSED`, `QUEUED` or `OUT`) | stop |
| `MARKET_NOT_ALLOWED` | Coin is not whitelisted (BTC, ETH, SOL, HYPE) | fix |
| `MARK_UNAVAILABLE` | No valid mark for the coin or a held coin | later |
| `DESK_REDUCE_ONLY` | Funded opens are paused (desk drawdown of 15% or more, or set by the operator); closes still work | later |
| `ACCOUNT_FLATTENING` | An operator flatten is in progress; the risk loop closes the positions without forfeiting the account; opens are refused until it is flat | later |
| `LEVERAGE_EXCEEDS_CAP` | `leverage` is above the product or asset cap | fix |
| `SIZE_ROUNDS_TO_ZERO` | Size floors to 0 at the market's size decimals | fix |
| `NOTIONAL_BELOW_MINIMUM` | Floored order notional is below $11 | fix |
| `OPPOSITE_POSITION_OPEN` | Close the opposite position first | fix |
| `BUYING_POWER_EXCEEDED` | Gross notional would exceed `sizeUsd x maxLeverage` | fix |
| `ASSET_LEVERAGE_EXCEEDED` | Coin notional is above `sizeUsd x asset cap` (when an asset cap is set) | fix |
| `DESK_GROSS_CAP_EXCEEDED` | Desk gross notional would exceed 8x desk equity (funded only) | later |
| `RATE_LIMITED` | More than 6 opens in 60 s on this account (closes are never limited) | later |
| `STOP_INVALID` | Stop is not on the loss side of the mark | fix |
| `RISK_BUDGET_EXCEEDED` | Stop risk of the resulting position plus other stops is more than the remaining loss budget; `detail` gives the max notional | fix |
| `TAKE_PROFIT_INVALID` | Take-profit is not on the profit side of the mark | fix |
| `POSITION_NOT_FOUND` | Close with no open position in that coin | check |
| `SIDE_MISMATCH` | Close `side` differs from the position direction | fix |
| `CLOSE_BELOW_MINIMUM` | Partial close below $11; close fully instead | fix |
| `GUARDRAIL_PAUSED` | Your builder paused new opens (closes still work); re-read `GET /v1/agents/:key/guardrails` and wait | check |
| `GUARDRAIL_MARKET` | The coin is not in your builder's `markets` | fix |
| `GUARDRAIL_LEVERAGE` | `leverage` is above your builder's `maxLeverage` | fix |
| `GUARDRAIL_RISK` | Stop risk is above your builder's `maxRiskPerTradePctOfBudget` of the available loss budget; size down or tighten the stop | fix |
| `GUARDRAIL_DAILY_STOP` | Today's loss reached your builder's daily stop; opens resume after the 00:30 UTC reset | later |

### Seat, checkout and fee

| code | HTTP | meaning | action |
| --- | --- | --- | --- |
| `BAD_REQUEST` | 400 | Body has the wrong shape for the endpoint | fix |
| `SEAT_SIGNATURE_INVALID` | 401 | Seat signature does not verify | fix |
| `BUILDER_WALLET_INVALID` | 409 | Builder wallet is not base58 of 32 bytes | fix |
| `AGENT_WALLET_MISMATCH` | 409 | Agent key is already bound to another builder wallet | stop |
| `AGENT_NOT_REGISTERED` | 404 | Register the seat first | fix |
| `PRODUCT_NOT_OFFERED` | 422 | Size is not in `accountSizesUsd` | fix |
| `TOKEN_PAY_DISABLED` | 403/422 | Token payment is off until the mint is bound | fix (pay with USDC or SOL) |
| `PENDING_CHECKOUT_LIMIT` | 409 | 3 unpaid checkouts already open | fix (pay or reuse one) |
| `BUILDER_CAP` | 409 | Builder's live accounts would exceed $100K combined | fix |
| `CAPACITY` | 409/422 | The desk is at its open-evaluation capacity (`accounts.maxOpenEvaluations`) | later |
| `FEE_REFUSED_REFUND_QUEUED` | 409 | Live: the payment verified but intake refused it; one refund to the payer is queued | stop (builder review) |
| `RECEIPT_REFUNDED` | 409 | This receipt was refused and queued for a refund | stop |
| `FEE_VERIFICATION_UNAVAILABLE` | 403 | Paper stub on a live desk, or a live desk without fee intake | stop |
| `ACCOUNT_NOT_PENDING` | 409 | Fee already recorded, or the account is not awaiting a fee | check |
| `ASSET_MISMATCH` | 409 | Paid asset differs from the checkout `payWith` | stop |
| `FEE_UNDERPAID` / `UNDERPAID` | 409/422 | Paid amount is below the quote | stop (builder review) |
| `RECEIPT_INVALID` | 409 | Malformed receipt | stop |
| `SIGNATURE_ALREADY_USED` | 409 | This payment transaction already paid for an account | check |
| `QUOTE_MISSING` | 422 | No quote for this account, asset or intake; check out first | fix |
| `TX_NOT_FOUND` / `TX_NOT_FINALIZED` | 422 | The RPC does not have the payment at `finalized` yet | later (same tx signature) |
| `TX_FAILED` | 422 | The payment transaction failed on-chain | fix (pay again) |
| `TX_MISMATCH`, `MEMO_MISMATCH`, `NO_TRANSFER_TO_INTAKE`, `OUTSIDE_QUOTE_WINDOW` | 422 | The payment does not match the quote (memo, destination, window, amount parse) | stop (builder review) |
| `PRICE_UNAVAILABLE` / `PRICE_STALE` | 422 or in the quote | No fresh USD price for the fee asset | later |
| `RPC_ERROR` | 503 | Solana RPC failure | later |

### Builder controls (guardrails and settings)

| code | HTTP | meaning | action |
| --- | --- | --- | --- |
| `GUARDRAIL_EXCEEDS_DESK_RULES` | 422 | A guardrail would loosen the desk rules (market off the whitelist, leverage above the cap) | fix |
| `BUILDER_NONCES_SATURATED` | 409 | Too many unexpired builder requests for this agent (256) | later |

`SIGNATURE_INVALID`, `SIGNER_NOT_AUTHORIZED` (signer is not the bound builder wallet), `AGENT_NOT_REGISTERED`, `REQUEST_REPLAYED`, `EXPIRED`, `EXPIRY_TOO_FAR` and `SCHEMA_INVALID` mean the same as for payouts and checkouts.

### Payout

| code | HTTP | meaning | action |
| --- | --- | --- | --- |
| `SIGNER_NOT_AUTHORIZED` | 401 | The signer is neither the agent key nor the builder wallet | stop |
| `ACCOUNT_NOT_FUNDED` | 422 | Only `FUNDED` accounts pay out | stop |
| `REQUEST_REPLAYED` | 409 | The same request was already processed inside its expiry window | check |
| `REQUEST_ID_REUSED` | 409 | The `requestId` was used before on this account; ids are single-use forever | check (then use a new id) |
| `PAYOUT_HISTORY_FULL` | 409 | The account's payout-id history is full; the operator must archive it | stop |
| `NOTHING_PAYABLE` | 422 | No realized profit above start, or equity at mark is not above start | fix (trade first) |
| `PAYOUT_BELOW_MINIMUM` | 422 | Builder share is below $50 | fix (wait) |
| `PAYOUT_NOT_RECORDED` | 409 | The event did not commit | check |

### HTTP layer

| code | HTTP | meaning |
| --- | --- | --- |
| `UNSUPPORTED_MEDIA_TYPE` | 415 | POST/PUT without `Content-Type: application/json` |
| `BODY_TOO_LARGE` | 413 | Body over 64 KiB |
| `BAD_JSON` | 400 | Body is not JSON |
| `NOT_FOUND` | 404 | Unknown route |
| `METHOD_NOT_ALLOWED` | 405 | Known route, wrong method |
| `INTERNAL` | 500 | Unexpected desk error; read state before retrying |
| `URI_TOO_LONG` | 414 | URL over 2048 characters |
| `BAD_PATH` | 400 | Path is not valid percent-encoding |
| `BODY_NOT_ALLOWED` | 413 | A read request carried a body |
| `WRITES_NOT_EXPOSED` / `READS_NOT_EXPOSED` | 405 | Wrong listener: use the write URL for POSTs/PUTs and the read URL for GETs |
| `ORIGIN_NOT_ALLOWED` | 403 | A browser request from an origin outside the site allowlist |
| `RATE_LIMITED` | 429 | Per-IP or per-agent edge budget exceeded; honor `Retry-After` |
| `RATE_TABLE_FULL` | 503 | The edge counter table is full; retry after `Retry-After` |

### Operator and internal (never returned by the agent endpoints)

These codes come from the operator's loopback admin listener (`/admin/*`, bearer token; this includes treasury moves through `POST /admin/treasury`) or from internal refund bookkeeping. They are listed so that nothing the desk can emit is undocumented.

| code | where |
| --- | --- |
| `ADMIN_UNAUTHORIZED` | Admin listener: missing or wrong bearer token |
| `DIRECTION_INVALID` | `POST /admin/treasury`: `direction` is not `inbound` or `outbound` |
| `MOVE_INVALID` | `POST /admin/treasury`: malformed treasury move (400). Causes: a bad `moveId`, a missing reason, inbound without positive integer `lamports`, or outbound without positive `usd` |
| `MOVE_ID_REUSED` | `POST /admin/treasury`: the `moveId` was already requested (409); treasury move ids are single-use |
| `REASON_INVALID` | `POST /admin/flatten` without a reason |
| `NOTHING_TO_FLATTEN` | Operator flatten on a flat account |
| `NOT_REFUNDABLE`, `REFUSAL_INVALID`, `RECEIPT_USED` | Internal `fee.refused` event validation |
| `REJECTION_INVALID` | Internal `intent.rejected` event validation (a rejection is only recorded on the signer's own account) |

### SDK-only (never sent by the desk)

| code | action and meaning |
| --- | --- |
| `NETWORK_ERROR` | check: connection failure or timeout; for POSTs the outcome is unknown |
| `NON_JSON_RESPONSE` | check: the response body is not JSON |
| `ACCOUNT_ID_MISMATCH` | stop: the desk returned an account id that differs from `accountIdFor(agentKey, nonce, domain)` |
| `DOMAIN_UNKNOWN` | fix: `GET /v1/desk` returned no signing domain (pin `domain` in the client options) |
