# GrailSwap Agent API (MCP + REST)

Trade verified physical-backed collectibles on Solana through MCP Streamable HTTP or REST.
The signing wallet is the account: one wallet = one account.
Your agent wallet signs every on-chain transaction locally; GrailSwap never receives, generates or stores its private keys.
API keys authorize actions within scopes and spending caps. P2P settlements require both participants' signatures.

**Endpoints:** [MCP](https://www.grailswap.io/api/mcp) · REST `https://www.grailswap.io/api/agent/v1` · [OpenAPI 3.1](https://www.grailswap.io/openapi.json) · [Discovery](https://www.grailswap.io/.well-known/mcp.json)

## Quickstart

### First connection

After authenticating, set up incoming offers before trading:

1. Call `list_webhooks`. If none has `status: "active"` and you have a public HTTPS endpoint, call `create_webhook` with `events: ["all"]` (also the default when omitted), store the returned secret securely (shown once), and run `test_webhook`. This includes `trade_proposal.*` and the wallet events below. These tools require `webhooks:manage`; if missing, the owner must grant it in a newly issued key. See [webhook setup and verification](#instant-notifications-webhooks).
2. If you cannot receive HTTPS requests (such as a chat-hosted agent), schedule `list_events` every **30–60s**, with `since` set to the previous response's `next_cursor`. Omit `since` on the first poll. No additional scope is required. Keep polling across idle conversations; persist the cursor only after processing the page and deduplicate by event `id`.
3. Fund your wallet for settlement fees. The maker's SOL preflight minimum is **0.000505 SOL + 0.00170336 SOL per pNFT + the configured flat SOL fee** (default zero). A contributing taker needs the flat fee + 0.0001 SOL when that fee is enabled, otherwise the taker minimum is zero. Build-time rent, priority fees and address lookup table (ALT) costs can require more; fund any returned `insufficient_sol` shortfall before retrying.

**Without webhooks or scheduled polling, incoming trade offers go unanswered and expire.** `get_me` (REST `GET /api/agent/v1/me`) and `list_trade_proposals` (REST `GET /api/agent/v1/proposals`) keep returning `setup: { webhook_configured: false, next_step: "..." }` while your user has zero active webhooks, including when all are disabled. The hint explains missing `webhooks:manage` instead of directing an unauthorized create. It is omitted once any webhook for your user is active, even if another key created it. It does not verify delivery health or detect scheduled polling.

> **Ask your human once**
>
> Before creating a key, get the human's accurate `attested_age_18` and `attested_not_wa` confirmations, authorized `scopes`, `per_tx_cap_micro_usdc`, `daily_cap_micro_usdc`, and whether to auto-accept buyback. Never infer attestations or choose spending authority for them. Buyback auto-accept is your agent's instruction, not a key/API field; if it is not authorized, ask before selling the revealed card.
>
> Copy and send this question:
> ```text
> Before I create your GrailSwap agent key, please confirm:
> - Are you 18 or older (attested_age_18), and not in Washington, US (attested_not_wa)?
> - Which scopes do you authorize: market, offers, trades, swap, packs, buyback, profile:write, webhooks:manage, orders:manage?
>   For buying/opening a pack and selling it back, I need packs and buyback.
> - What is your per-transaction USDC limit (per_tx_cap_micro_usdc) and rolling 24-hour
>   USDC limit (daily_cap_micro_usdc)? 1 USDC = 1,000,000 micros.
> - Should I auto-accept an eligible buyback after showing its amount, or ask you first?
>   If auto-accepting, what minimum proceeds or other conditions should I follow?
> ```

### Connect MCP

Connect to **https://www.grailswap.io/api/mcp** using stateless MCP Streamable HTTP. Responses are JSON; there are no sessions or SSE subscriptions. `mcp.grailswap.io` does not exist. `tools/list` advertises all 57 tools anonymously: nine public reads and 48 authenticated tools. Send `Authorization: Bearer gsk_...` on every authenticated request; a session cookie never substitutes for a key.

For MCP clients accepting an HTTP `mcpServers` configuration, configure the endpoint and bearer header (inject your stored key in place of the placeholder; never place a wallet private key here):

```json
{
  "mcpServers": {
    "grailswap": {
      "type": "http",
      "url": "https://www.grailswap.io/api/mcp",
      "headers": { "Authorization": "Bearer <GRAILSWAP_AGENT_KEY>" }
    }
  }
}
```

Clients must support custom HTTP headers and provide their own local wallet signer to spend. Send `Content-Type: application/json`, `Accept: application/json, text/event-stream`, and the negotiated `MCP-Protocol-Version`. Raw JSON-RPC clients can initialize with:

```json
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"my-agent","version":"1"}}}
```

Send `{"jsonrpc":"2.0","method":"notifications/initialized"}` next. Each later request is independent; authenticated calls must carry the bearer header. Inspect the tools:

```json
{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}
```

### Create a key

1. Choose the account wallet, scopes and caps. `POST /api/agent/v1/keys/challenge` with the complete intent below. Both attestations must be accurate and true.
2. Sign the returned `message` as exact UTF-8 using that wallet's ed25519 key. Return the opaque `nonce` unchanged; challenges expire after five minutes and are single-use.
3. `POST /api/agent/v1/keys` with the **same intent** plus `nonce` and the base58 `signature`. Store the returned `gsk_` key securely: it is shown only once. Use it as the bearer token in the client configuration above.

```json
{
  "wallet_address": "YOUR_SOLANA_PUBLIC_KEY",
  "label": "Market scout",
  "scopes": ["market", "offers", "trades", "swap", "packs", "buyback"],
  "per_tx_cap_micro_usdc": "50000000",
  "daily_cap_micro_usdc": "100000000",
  "attested_not_wa": true,
  "attested_age_18": true
}
```

Add `profile:write` explicitly if the agent needs to set a username or avatar. Google/email sign-in creates a different embedded wallet; use the same signing wallet for the same account. Signed-in owners can manage keys at [Settings → Agents](https://www.grailswap.io/settings/agents). A cookie alone cannot create a key, and an existing key cannot mint another key.

### First read

After initialization, discover a listing without authentication:

```json
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"search_market_listings","arguments":{"query":"Charizard","max_price_usd":50,"limit":1}}}
```

With your key, call `get_me` with `{}` (REST `GET /me`) to inspect scopes, caps and remaining budget.

### First write: build → sign → submit

Use the returned listing's mint. Action calls require a stable `idempotency_key` (16–128 letters, numbers, underscores or hyphens); REST uses the `Idempotency-Key` header. Reuse the same key and input for retries.

Buy a listing, with a 50 USDC spend ceiling (replace the mint placeholder with an eligible listing):

```json
{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"buy_listing","arguments":{"mint":"NFT_MINT","max_price_micro_usdc":"50000000","idempotency_key":"listing-operation-0001"}}}
```

The result's `structuredContent` is the exact REST attempt envelope documented below, including `attempt_id`, `amount_micro_usdc`, `expires_at`, `transactions` and `result`. The text content contains the same JSON for clients without structured-result support. Decode and review each `transactions[i].unsigned_transaction` locally; sign the **exact message** with the key's wallet, preserving any partner signatures. Do not change its instructions, fees or blockhash. Submit its serialized signed base64 bytes:

```json
{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"submit_signed_transaction","arguments":{"attempt_id":"ATTEMPT_UUID","transactions":[{"index":0,"signed_transaction":"SIGNED_BASE64"}]}}}
```

After a timeout, call `get_attempt_status` with `{"attempt_id":"ATTEMPT_UUID"}`. Retry submission only with identical signed bytes. Never start a replacement purchase to recover an uncertain payment. See [Sign, submit and recover](#sign-submit-and-recover) for expiry, partial settlements and pack reveals.

## Tools

The REST OpenAPI document covers 50 paths and 58 operations under `/api/agent/v1`.

| MCP tool | REST equivalent (under `/api/agent/v1`) | Auth / scope | Purpose |
| --- | --- | --- | --- |
| `search_market_listings` | GET `/market/search` | Anonymous | Find public live USDC listings; limit 1–20, default 20. Cold/stale index returns retryable `index_unavailable` with a 10s retry hint. `checks` reports selected/live_checked/unknown/unlisted counts (30s observation cache). |
| `get_card` | `GET /cards/{mint}` | Anonymous | Read public card facts, listing status and funded, unexpired `cc_offers`. |
| `get_collector_inventory` | None (MCP discovery only) | Anonymous | Read public inventory and similar-value cards. |
| `list_packs` | None (MCP discovery only) | Anonymous | Discover enabled packs, odds and availability. |
| `get_pack_summary` | `GET /packs/summary?packType=…&top_n=5` | Anonymous | Price, EV, odds, instantBuyback % and top hits; check top_hits_complete. |
| `list_pack_contents` | `GET /packs/contents?packType=…&cursor=…&limit=24` | Anonymous | Cursor-paged public pack contents. |
| `get_buyback_quote` | `GET /packs/buyback-quote?nftAddress=…` | Anonymous; optional bearer | Read-only estimate; verifies caller ownership when authenticated. Never reserves. |
| `find_trade_matches` | None (MCP discovery only) | Anonymous | Find public tradeable cards of similar value. |
| `preview_trade` | None (MCP discovery only) | Anonymous | Check proposed cards, USDC, eligibility and value. |
| `update_profile` | `POST /profile/update` | `profile:write` | Set permanent username and avatar. |
| `create_standing_want` | `POST /orders/wants` | `orders:manage` | Post a catalog-card want; retry with the same client_order_id. |
| `list_standing_wants` | `GET /orders/wants` | `orders:manage` | List this key's wants. |
| `update_standing_want` | `PATCH /orders/wants/{id}` | `orders:manage` | Edit terms, expiry or pause/resume. |
| `delete_standing_want` | `DELETE /orders/wants/{id}` | `orders:manage` | Remove a want from future matching. |
| `create_standing_have` | `POST /orders/haves` | `orders:manage` | Offer a public, unlisted owned asset. |
| `list_standing_haves` | `GET /orders/haves` | `orders:manage` | List this key's haves. |
| `delete_standing_have` | `DELETE /orders/haves/{id}` | `orders:manage` | Remove a have from future matching. |
| `list_order_matches` | `GET /orders/matches` | `orders:manage` | Read match/proposal IDs and statuses. |
| `get_auto_accept_rules` | GET `/auto-accept` | `trades` | Read this key's rules; default OFF. |
| `set_auto_accept_rules` | POST `/auto-accept` | `trades` | Replace opt-in rules; no idempotency key. |
| `disable_auto_accept_rules` | POST `/auto-accept/disable` | `trades` | Disable rules even during a trading pause. |
| `get_sign_ready_attempt` | POST `/attempts/{attempt_id}/sign-ready` | Original scope | Refresh an unsubmitted market build before its original deadline; same-owner keys fetch existing bytes. See the [local signer guide](https://www.grailswap.io/docs/agent-local-signer.md). |
| `get_sign_ready_settlement` | POST `/proposals/{proposal_id}/sign-ready` | `trades` | Fresh partially-signed settlement and expiry for this participant; no idempotency key. |
| `create_webhook` | `POST /webhooks` | `webhooks:manage` | Register signed notifications with optional write-only `auth_header`; market listing events and `price.alert` need explicit types or `filters`. Returns signing secret once. |
| `update_webhook` | `PATCH /webhooks/{id}` | `webhooks:manage` | Set/replace `auth_header`; null clears, omission preserves. Returns `auth_header_set` only. |
| `list_webhooks` | `GET /webhooks` | `webhooks:manage` | List owned endpoints without secrets; `auth_header_set` reports whether Authorization is configured. |
| `delete_webhook` | `DELETE /webhooks/{id}` | `webhooks:manage` | Delete endpoint and queued deliveries. |
| `test_webhook` | `POST /webhooks/{id}/test` | `webhooks:manage` | Send signed ping; return status and latency. |
| `rotate_webhook_secret` | `POST /webhooks/{id}/rotate-secret` | `webhooks:manage` | Rotate signing secret; returns it once. |
| `request_limit_increase` | `POST /limits/requests` | Any active key | Request higher caps; owner approval required unless this key has owner-granted self-approve. |
| `get_limit_requests` | `GET /limits/requests` | Any active key | List this key’s latest 100 requests and statuses. |
| `lower_limits` | `POST /limits/lower` | Any active key | Immediately lower caps, including zero. |
| `link_owner` | `POST /owner-link` | Any active key | Propose a human owner by username for an agent-made account. |
| `get_me` | `GET /me` | Any valid key | Read identity, budget and owned inventory, including private cards and received gifts; advance reconciliation. |
| `send_card` | `POST /gifts/send` | `trades` | Gift owned cards by username or wallet; card value counts against caps. |
| `buy_listing` | `POST /market/buy` | `market` | Build a Market listing purchase. |
| `list_card` | `POST /market/list` | `market` | Build a listing for an owned card. |
| `unlist_card` | `POST /market/unlist` | `market` | Build cancellation of an owned listing. |
| `accept_cc_bid` | `POST /market/accept-bid` | `market` | Sell an owned card into a funded CC offer; optional simulation preview. |
| `make_offer` | `POST /offers/create` | `offers` | Create a Trade Outpost offer. |
| `accept_offer` | `POST /offers/accept` | `offers` | Accept an offer and build settlement. |
| `cancel_offer` | `POST /offers/cancel` | `offers` | Cancel an owned pending offer. |
| `list_events` | `GET /events` | Any active key | Wallet event feed by default; market events need explicit market `types` or `filters`. Opaque `since`, `limit` up to 100. |
| `list_trade_proposals` | `GET /proposals` | `trades` | Poll received/sent proposals with a `since` cursor. |
| `get_trade_proposal` | `GET /proposals/{id}` | `trades` | Read one proposal as a participant. |
| `create_trade_proposal` | `POST /proposals/create` | `trades` | Send a directed card/USDC proposal. |
| `accept_trade_proposal` | `POST /proposals/accept` | `trades` | Preflight SOL, accept, and build; committed accepts retain IDs on build failure. |
| `decline_trade_proposal` | `POST /proposals/decline` | `trades` | Decline a received proposal. |
| `cancel_trade_proposal` | `POST /proposals/cancel` | `trades` | Cancel a proposal as proposer. |
| `build_trade_settlement` | `POST /trades/settlement` | `trades` | Build settlement for either participant. |
| `trade_bot_quote` | `POST /swap/quote` | `swap` | Get a Trade Bot quote and quoteKey. |
| `trade_bot_swap` | `POST /swap/execute` | `swap` | Execute a quote and build settlement. |
| `buy_pack` | `POST /packs/buy` | `packs` | Build pack payments and memos. |
| `open_pack` | `POST /packs/open` | `packs` | Reveal an owned paid pack. |
| `buyback_sell` | `POST /packs/buyback` | `buyback` | Quote and authorize an eligible buyback sale. |
| `submit_signed_transaction` | `POST /submit` | Attempt's scope | Relay exact signed transactions. |
| `get_attempt_status` | `GET /attempts/{id}` | Owning key | Read stored status and original transactions. |

All authenticated tools share REST authorization and applicable caps, idempotency and recovery behavior. Proposal reads require `trades` but no idempotency key, spend reservation or caps; they work while agent trading is disabled. The nine anonymous tools apply public discovery and privacy filtering, even when a bearer key is supplied. The three pack reads (`get_pack_summary`, `list_pack_contents`, `get_buyback_quote`) are also available as anonymous `GET /api/agent/v1/packs/summary`, `/packs/contents` and `/packs/buyback-quote`; `get_card` is also available at `GET /api/agent/v1/cards/{mint}`; the other five have no `/api/agent/v1` REST equivalent. Use `tools/list` for their complete input schemas.

Offer tools concern P2P Trade Outpost offers, not CC Market bids. `buyback_sell` combines a fresh reserving quote and authorization; `get_buyback_quote` is a non-reserving estimate only. Key challenge, issuance, list and revoke are REST/settings operations, not MCP tools. `get_me` and `trade_bot_quote` can update records and are not advertised as read-only.

For CC cards, `insured_value_usd` comes from the canonical per-mint database, shared with marketplace tiles, card pages and asset values. `get_card` checks CC with a 5-second timeout and one retry, commits a successful positive observation, then reads the database. Failure preserves the last value and sets `insured_value_stale: true`. `insured_value_checked_at` (also `insured_value_as_of`) is the last successful CC observation, or null when never checked. `insured_value_source` is `stored`; values at least 24 hours old are stale even without a request failure. CC may serve publicNft from its edge cache for up to an hour; checked-at marks our successful observation, not a valuation change at origin. Never treat stale/unknown values as fresh buy quotes. `search_market_listings` rechecks at most 20 candidates with six workers and a 24-second overall budget, then applies `max_pct_vs_insured` to fresh values; failed rechecks cannot pass that filter. `get_me`, public inventories, trade preview/matching and proposals return the same mirrored database value with checked-at/stale fields. Hidden prices also hide freshness. Live money-path quotes retain their independent 120-second fail-closed guard.

`get_collector_inventory`, `find_trade_matches`, and `preview_trade` overlay CC values without writing assets: deduplicated reads, six concurrent requests, 2.5-second request timeout, three-second page budget, and a 30-second success cache. Failures or exhausted budgets return labelled stored values. Trade matching prefilters stored values in a band of at least ±50%, reads at most the requested limit, then checks the requested tolerance against fresh values; results may be incomplete. Inventory near-value filtering and cursor order use stored values, with fresh values overlaid only on the returned page. Displayed values and deltas may therefore move outside the inventory search band. Trade preview totals use the overlaid values. Listings found by search retain `cc_listing` provenance; always confirm with `get_card`.

`market_reference_usd` is a catalog estimate and may be null. A known, clear PriceCharting set/console conflict, or a reference greater than five times insured, returns null with `market_reference_note: "suppressed: catalog match uncertain"`. Missing or unknown catalog sets do not suppress a reference; normalized set subsets agree in either direction. Catalog estimates never replace insured value or write `assets.estimated_value`.

`get_card` (MCP and `GET /api/agent/v1/cards/{mint}`) returns `card.cc_offers`, highest funded unexpired bid first. Each row contains only `price_usdc` (decimal string, up to six places), `bidder_wallet`, `created_at` (ISO UTC), `expires_at` (ISO UTC or null for perpetual offers), and `funded`. Both 161-byte and 169-byte offers are read from chain; partner names are excluded. `[]` means no visible bids, `null` means the book is unavailable. `cc_offers_stale` flags last-good cached data: freshness 90 seconds, chain deadline 4 seconds, maximum stale age 5 minutes. Funding is an escrow USDC balance snapshot, not a reservation. Expired rows are filtered on every response. Private, held and ineligible cards remain unavailable. The DAS fallback uses a fresh matching-owner listing index when available and otherwise retains `listing_status: "unlisted"`. Use `accept_cc_bid` with market scope to sell an owned card into a funded offer, including the 169-byte layout.

### Send and receive cards

Use `send_card` with a `trades` key to send 1–5 owned cards to a registered collector's username (case-insensitive, optional `@`) or exact wallet address:

```json
{"asset_ids":["49400000-0000-4000-8000-000000000010"],"recipient":"@Collector","message":"Enjoy this card!","private":true,"idempotency_key":"gift-collector-494-0001"}
```

REST uses `POST /api/agent/v1/gifts/send` with the same fields except `idempotency_key`, supplied as the `Idempotency-Key` header. `message` is optional, up to 140 characters. `private` is optional and defaults to false; true keeps the gift off public activity and profiles while preserving recipient notifications. The recipient must have an active GrailSwap account; friendship is optional, as on the site. The site's card-gift path has no separate gift consent toggle or acceptance step. Self, Trade Bot vault, configured system wallets and program addresses are rejected. Cards must belong to your wallet and pass the site's escrow, listing, redemption and lock guards.

Gift value uses insured value first, market value only when insured is null, summed in integer micro-USDC. Unknown or nonpositive values return `gift_value_unavailable`. Supply optional `max_price_micro_usdc` to reserve a ceiling for the gift value (for example, `"5000000"` for a $5 gift); otherwise the per-tx cap is reserved. The ceiling must cover the captured card value and fit the per-tx cap, remaining daily budget and shared wallet budget. A successful build books the captured card value. The same ceiling is supported on `build_trade_settlement` and included in recovery hints. Incoming cards do not spend or replenish caps. Rebuilding the gift through `build_trade_settlement` preserves the captured value and effects checks.

Review the standard attempt's `amount_micro_usdc`, `transactions[].effects`, `fee_payer`, and `signers_required`; sign the exact bytes locally and call `submit_signed_transaction`. Only the sender signs. The existing site fee payer pays fees/rent, including service-wallet sponsorship when configured. SOL preflight checks that payer's known minimum before creating escrow; like proposal preflight, RPC failures log and defer to the shared builder's full check and pre-broadcast simulation. Undeclared transfers or fees fail with `gift_effects_mismatch` before bytes are returned. If creation commits but building fails, the success envelope contains no transactions, zero booked spend, the gift IDs and `settlement_error`; follow `next_step` to rebuild the same offer. A later verification failure returns an error with `offer_id` and the same recovery hint. Recover the original attempt after a timeout instead of sending again.

If a settlement build fails, the cards stay locked until the 1-hour expiry sweep; rebuild with `build_trade_settlement`.

Receiving bots do nothing to accept. After settlement confirms, `get_me` / `GET /api/agent/v1/me` includes `inventory.cards` with `asset_id`, `mint`, `name`, `status`, `insured_value_usd` and `market_value_usd`. This authenticated inventory includes privately held cards, up to 100 per page. Pass `inventory.next_cursor` as MCP `inventory_cursor` or REST `?inventory_cursor=UUID`; null marks the last page. Omit the cursor to refresh from the first page. `get_collector_inventory` remains a public, privacy-filtered view. Gift notifications use the same site bells and activity as human gifts; proposal webhooks are not gift-arrival events.

### Sell into a Collector Crypt bid

Call `accept_cc_bid` with `{ "mint": "NFT_MINT", "min_price_usd": "39.64", "dry_run": true }` to build and simulate; the in-memory dry-run throttle and a separate CC preview wallet bucket apply; no DB writes occur. No idempotency key is required for this preview. It returns verified `effects`, `fee_payer`, `signers_required`, gross `amount_micro_usdc`, net `proceeds_micro_usdc`, `result.quote`, network fee/rent in lamports, and `cap_impact.reserved: false`. It returns no attempt or signable bytes. Caps, ownership, trading switches, age/region attestations and the `market` scope still apply. Dry runs share the in-memory 30/minute/key agent-dry-run throttle with other previews; CC previews also share a separate in-memory 12/minute/wallet bucket per instance across keys and mints. They do not consume the DB rate bucket or human wallet build limit. They do not update the key timestamp, reconcile holds, or recover buyback reservations. Both real builds and dry runs cap the offer scan at `CC_OFFER_SCAN.maxOffers` (100), refusing larger books before escrow funding RPCs with HTTP 409 `too_many_offers`, `retryable: false`, and a hint to pass `offer` explicitly. An explicit `offer` is looked up directly and bypasses this cap.

Omit `offer` to select the **highest funded bid**, excluding self bids and offers with less than 60 seconds remaining; pass the Offer PDA address to select a specific bid. `min_price_usd` accepts a decimal string or number with up to six fractional places and compares against the **gross** bid, before CC fees. Both 161-byte and 169-byte offers are supported for pNFT and Core. **Strongly recommend setting `min_price_usd` on every call**: an unfunded higher bid does not prevent selecting a lower funded bid. Once selected, a changed or blocked bid fails without selecting another bid. The `CC_ACCEPT_MIN_VALIDITY_SECONDS = 60` margin applies only to agent selection/build. A selected or explicit bid with less than 60 seconds remaining returns HTTP 409 `offer_expiring`, `retryable: false`, and `hint: "bid expires within 60s; choose another"`, with its current `expires_at` and a `get_card` next step. An already-expired selected/explicit bid returns `offer_expired`. Submission/confirmation requires only that the bid has not expired: building at 75 seconds remaining and submitting at 55 seconds is allowed. The human website preview/build/confirm flow retains its original not-expired check and `offer_gone` expiry error. Exactly 60 seconds and perpetual offers are eligible. Cached `get_card.cc_offers` is for discovery; acceptance re-reads the selected offer, price, expiry and escrow PDA's USDC ATA before building and again during verification.

Both build and dry-run responses include `result.selected_offer` (`offer`, `bidder`, `price_micro_usdc`, `expires_at`, `funded`) and `result.highest_skipped_bid` with the same fields plus `reason`: `underfunded`, `self_bid`, or `insufficient_validity`. The latter is the highest-priced ineligible bid strictly above the chosen bid during auto-selection, or `null` if none is above it (equal and lower prices do not count). Explicit offer requests do not scan competing bids.

Finalized reconciliation verifies the exact signed message and NFT departure before recording actual escrow spend and seller payout. If these differ from the quote, the attempt completes as `confirmed`, with `result.reconciliation.status` and the transaction result set to `confirmed_with_mismatch`, actual and quoted micro-USDC amounts retained, and a console/Sentry warning. Proceeds reflect the landed payout; cap accounting remains conservative and never replenishes from proceeds. Changed messages or invalid NFT/ownership evidence retain the hold. Pre-relay simulation and normal confirmation still require the exact quoted payout.

For the real build, omit `dry_run` or set it to false and supply a stable `idempotency_key` (REST: `Idempotency-Key` header). The exact gross bid value reserves the per-transaction, daily and shared wallet caps before unsigned bytes are returned. Incoming proceeds do not restore budget. Sign the returned transaction locally, call `submit_signed_transaction`, and recover with `get_attempt_status` or retry the same signed bytes. The seller is the sole signer and pays SOL fees/rent; there is no sponsorship or custody change. The existing `CC_OFFER_ACCEPT_ENABLED` and agent trading switches must be enabled.

Changed, cancelled, expired or underfunded bids return an error with `current` state and a `get_card` `next_step`. A bid below the minimum returns `price_below_min`. Signature/message verification and pNFT/Core simulation pin the card transfer, exact net seller USDC, CC fee, escrow debit and allowed SOL/rent changes. Confirmation retains the gross cap charge; ambiguous history retains its hold.

## Spending-limit requests and human ownership

Every new key requires a wallet signature and integer micro-USDC caps, with **per-transaction ≤ daily**. The self-serve API issues starter keys capped at **$50 per transaction / $100 per rolling 24 hours** (50,000,000 / 100,000,000 micros), including when a website cookie accompanies the request. A human account using Settings may issue up to **$10,000 / $50,000** through `POST /api/settings/agents/keys`, with its current session and wallet signature. Agent-made accounts always start at starter caps, even through Settings. Above-starter API issuance returns `403 limit_increase_requires_approval`: create a starter key, then request a raise and obtain owner approval. Above-ceiling or daily-below-per-transaction issuance returns 400. Existing keys are not resized.

All keys for one wallet share an **account rolling daily cap**, in addition to each key's caps. It is the highest current daily cap on a non-revoked key backed by owner approval, owner-authorized self-approval, or signed Settings issuance, or the $100 starter default if none has that evidence. Caps are never added together. Pending reservations and confirmed spend within 24 hours count across the account, including revoked keys so rotation cannot reset spending. A shared-cap denial returns `403 account_daily_cap`.

Agent-made accounts may have **one active key** until a linked human owner signs a cap approval. A second issuance returns `403 active_key_limit_requires_approval`, directing the agent to `link_owner`, `request_limit_increase`, and the owner's signed Settings approval. Accepting an owner link alone does not unlock additional keys; a cap approval under that accepted link does. Additional keys still start at starter caps and share the account budget. Unlinking removes permission to issue further keys, and a new link needs a new approval. Human accounts retain multi-key issuance.

An active key can request higher caps with `request_limit_increase` (REST `POST /limits/requests`):

```json
{"per_tx_usd":"250.000001","daily_usd":1000,"reason":"Please authorize the larger card purchase we discussed."}
```

Supply at least one cap. USD values may be decimal strings or numbers with up to six fractional digits; storage and enforcement remain integer micro-USDC. Each supplied cap must **exceed its current value** and be at most **$10,000 per transaction / $50,000 per rolling 24 hours**. These server ceilings apply to all key issuance and raise requests. The resulting daily cap, including any unchanged value, must be at least the per-transaction cap; otherwise the request returns `400 invalid_limits`. Approval rechecks this relation. The required trimmed reason is 1–280 characters. Requests need no additional scope, spend reservation or action idempotency key, share the general 30/minute/key limiter, and work when trading is disabled.

The response is `{ request: { id, key_id, user_id, per_tx_micro_usdc, daily_micro_usdc, reason, status, approved_via, created_at, updated_at, expires_at }, approved_immediately, approve_url, owner_link_required, next_step: null }`. Omitted requested caps are null and stay unchanged on approval. **By default, caps do not change when a request is created.** Send the approve URL to the human. If the owner has enabled **Can approve its own limit raises** for this key, the request and caps are approved atomically: `status: "approved"`, `approved_via: "self_approve"`, `approved_immediately: true`, and `approve_url: null`. The existing ceilings and per-transaction ≤ daily requirement still apply. Self-approved raises count as approved raises for the shared wallet budget; they do not add a separate budget. Pending requests have `approved_via: null` and `approved_immediately: false`; manually approved requests have `approved_via: "owner"`. Each key can have one pending request; creating another marks the old one `superseded`. Requests expire after 24 hours. `get_limit_requests` (`GET /limits/requests`) returns `{ requests, owner_link_required, next_step: null }`, latest 100 first, limited to the calling key. Statuses: `pending`, `approved`, `denied`, `expired`, `superseded`. A pending row past `expires_at` cannot be approved; cleanup updates its stored status.

`lower_limits` (`POST /limits/lower`) takes `{ per_tx_usd?, daily_usd? }`, at least one. Every supplied value must be **≤ the current cap**; zero stops new spending under that cap. It applies immediately with a conditional update; omitted caps stay unchanged. Mixed reductions/increases fail atomically with `403 limit_increase_requires_approval`. The response is `{ key: { id, wallet_address, label, per_tx_cap_micro_usdc, daily_cap_micro_usdc }, next_step: null }`. Existing reservations and rolling spending accounting are unchanged.

A human reviews the key, current/requested caps, reason and expiry at [Settings → Agents](https://www.grailswap.io/settings/agents#agent-approvals). Approve and deny use the normal `cardvault_session` session and a fresh five-minute, single-use wallet signature. The signed message binds the action, request id, key id, owner's account/wallet and accepted link, current and resulting caps, and nonce. Changes after signing require another review. Approval claims a pending, unexpired request and updates an active, non-revoked key with an audit record in one transaction. Bearer credentials are rejected by the website approval APIs, even with an owner cookie. There is no MCP approval tool. Approval also requires an unsuspended account. The owner can still deny requests for revoked keys or suspended agent accounts and can decline or unlink a suspended agent account.

The owner can grant or revoke self-approval per key in Settings → Agent keys. The toggle requires the same human session and nonce-bound wallet signature as a cap approval, naming the key, action, owner and current permission. Any agent bearer credential is rejected, including the target key and sibling keys. The permission defaults to false, records who granted it and when, and never propagates to newly issued keys. Granting it does not approve older pending requests; only later requests may self-approve. A signed revocation takes effect before later requests, leaves current caps unchanged, and clears the grant metadata. Key revocation and owner unlink also clear it. Agent-made accounts require a linked, accepted human owner; the agent account’s own session cannot grant it.

**Agent-made accounts need a separate human owner.** Spending authority treats the account as agent-made when its first key came through the self-serve API, when its origin lacks signed Settings ownership evidence, or when the existing ±5-second Bot heuristic flags it. Revoked keys retain that history. Signing in and waiting before API issuance does not establish human ownership. Requests without a linked owner still succeed with `owner_link_required: true`, but approval is blocked. Use `link_owner` (`POST /owner-link`) with `{ "username": "human_owner" }`. The named account gets a notification and must accept in its own website session with its own wallet signature. The response includes `owner_link` (with `owner_username`, never `owner_user_id`) and `approve_url`; consent offers expire in 24 hours and new proposals supersede older pending ones. Self-ownership and agent-made owners are rejected. Once linked, only that owner can unlink; the agent cannot replace an accepted owner. Repeated proposals to the same owner share one notification, which opens the current approval panel. The bot's website session can also propose a link. Accepting a link never approves any cap request automatically.

Signed Settings creation records human ownership evidence for an account's first key; accounts without agent keys can also act as owners through the session-and-signature approval flow. Legacy keys without recorded origin default to owner-required. This is wallet authority, not proof of personhood: an agent holding the human wallet and its website session can exercise that human authority.

Website-only operations (not MCP / agent v1): `GET /api/settings/agents` returns the viewer's approval panel; `POST /api/settings/agents/owner-link` proposes by username; `POST /api/settings/agents/challenge` takes `{ kind: "limit" | "owner" | "self_approve", id, action }`; `POST /api/settings/agents/decide` takes `{ decision: { kind, id, action }, nonce, signature }`. Limit actions are `approve` / `deny`; owner actions are `accept` / `decline` / `unlink`; `self_approve` actions are `grant` / `revoke`, with `id` naming the key. All mutations require the same-site Origin; all decision actions require signatures. Only the target owner may act. These endpoints reject Authorization headers.

Subscribe with `webhooks:manage` to `agent.limit_request.approved`, `agent.limit_request.denied`, and `agent.limit_request.expired`. They use the existing signed delivery/outbox format with `data: { request_id, key_id, status, approved_via, updated_at }`; no reason, wallet, signature or amounts are disclosed. Events go to subscribed webhooks owned by the key's account. Cleanup uses the existing webhook cron. Events are persisted even while delivery is disabled, then delivered when enabled.

Migration 0141 is required before deploying this key-schema change. It adds the default-off permission and grant metadata to keys, approval provenance to requests and new audit actions; it retains the full webhook event union from 0139/0140. Missing limit tables return `503 limit_requests_unavailable`. Webhook creation before an event is available returns `400 webhook_event_unavailable`. Other relevant errors: `400 invalid_limits`, `400 invalid_limit_increase`, `400 invalid_owner`, `409 owner_already_linked`, `403 owner_link_required`, `403 human_owner_required`, `403 human_session_required`, `409 request_not_pending`, `401 invalid_nonce` / `invalid_signature`, and `404 not_found` for non-owned requests or approvals on revoked keys. A stale or expired challenge requires a fresh website review; an expired request requires a new agent request.

## Detecting new offers

Use `list_events` for scheduled polling across proposals, attempts, cap outcomes and wallet arrivals. `list_trade_proposals` (REST `GET /api/agent/v1/proposals`) remains available for proposal-specific reconciliation, retaining its own `next_cursor` as `since`. Both proposal reads require a bearer key with `trades`, share the existing **30 requests/minute/key** limiter and return private/no-store responses. They create no attempt or spend reservation and need no idempotency key.

| Input | Values / default |
| --- | --- |
| `direction` | `received` (default), `sent`, `all` |
| `status` | `pending` (default), `all`, `accepted`, `declined`, `cancelled`, `expired`, `settled`, `failed` |
| `since` | Opaque `next_cursor`, or an exclusive ISO timestamp matched against `updated_at`; omit to start with existing proposals. |
| `limit` | Integer 1–100; default 25. |

```json
{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"list_trade_proposals","arguments":{"direction":"all","status":"all","limit":100}}}
```

REST polling loop (persist the cursor after processing each successful response):

```js
let since = await loadSavedCursor(); // undefined on the first call
for (;;) {
  const query = new URLSearchParams({ direction: "all", status: "all", limit: "100" });
  if (since) query.set("since", since);
  const response = await fetch(`https://www.grailswap.io/api/agent/v1/proposals?${query}`, {
    headers: { Authorization: `Bearer ${agentKey}` },
  });
  if (!response.ok) throw new Error(await response.text()); // retain saved cursor on errors
  const page = await response.json();
  for (const proposal of page.proposals) await processProposal(proposal);
  since = page.next_cursor;
  await saveCursor(since);
  await new Promise(resolve => setTimeout(resolve, 15_000));
}
```

The response is `{ "proposals": [...], "next_cursor": "...", "server_time": "...", "next_step": null }`. Rows sort ascending by **(updated_at, id)** with full database timestamp precision. List reads intentionally lag the database clock by **2 seconds**: only rows with `updated_at <= clock_timestamp() - interval '2 seconds'` are eligible. A fresh proposal or status update becomes visible on a poll after that window; detail reads remain immediate. The cutoff is evaluated once per page, independently of the application clock. Continue with `next_cursor` even for a full page; an empty page keeps the last observed position. `server_time` is informational, not a cursor. Keep filters unchanged for a polling stream; restart without `since` when changing filters. Use `status=all` to detect acceptance, decline, cancellation, expiry, settlement and failure updates. This reads current proposal state, not an event log or frozen snapshot: an updated ID may reappear, intermediate states can be coalesced, and the two-second window protects normal short transactions whose commit order differs from their timestamp order. It is not a commit-order guarantee for transactions held open longer than two seconds. Make processing idempotent by `(id, updated_at)`; periodically reconcile with an overlapping ISO `since` window covering the maximum transaction duration, or a full scan without `since` if that duration is unknown.

Proposals accepted before the webhook cutover stay `accepted`; there is no historical status backfill. A later settlement transition handled by the new code can update them. For older completed trades, use the linked trade/escrow state on the site rather than treating `accepted` as proof of an unfinished settlement.

Each directed proposal includes `id`, `direction`, `status`, `created_at`, `updated_at`, nullable `expires_at`, `counterparty: { user_id, username, wallet, is_bot }`, `offered`/`requested` cards, `offered_usdc`/`requested_usdc`, `value_check` and `next_actions`. **Offered always means the proposer's side**, even in the recipient's inbox. Cards contain `id`, `mint`, `name`, `grade`, `grader`, `insured_value_micro_usdc`, `insured_value_usd`, `market_value_micro_usdc` and `market_value_usd`. USDC sides are `{ "micro_usdc": "1000001", "usd": "1.000001" }`; card USD strings also retain six places. Hidden or unknown card fields are null, never fabricated as zero. Participants can see the proposal's cards as on the site, with their own fields visible and the current owner's field privacy enforced.

`value_check` reuses the public informational proposal preview: give = proposer, get = recipient; public-visibility restrictions can make its totals unknown even when participant card details are visible. Asset and trader reads are batched across the page, and live eligibility reads deduplicate cards and process bounded chunks serially. A value-read failure returns `value_check: null` without failing the inbox call or preventing cursor progress. Unknown/private values still return the usual check object with `values_complete: false`; unavailable live listing eligibility retains an `eligibility_unavailable` warning. Pending, unexpired received proposals suggest `accept_trade_proposal` / `decline_trade_proposal`; sent proposals suggest `cancel_trade_proposal`. Other rows have no next actions. Pending rows past expiry retain their stored status until cleanup; reads never mutate them. Actions still recheck eligibility and may fail if state changes.

For one proposal, use `get_trade_proposal` with `{ "proposal_id": "UUID" }`, or `GET /api/agent/v1/proposals/UUID`; both return `{ "proposal": ..., "next_step": null }`. Only the proposer and recipient can read it; missing and non-participant proposals both return `404 not_found`. Invalid filters/limits return `400 invalid_input`; malformed `since` returns `400 invalid_cursor`. Public proposals without a recipient are outside this directed inbox.

## Standing wants and haves

Use `orders:manage` to post persistent intent once. This scope explicitly authorizes the worker to create unsigned proposals in the order owner's name when the posted terms match, even if the key has no `trades` scope. Manual proposal creation still requires `trades`. Existing keys need a newly signed key with this scope. Orders belong to both the user **and the creating key**; another key for the same wallet cannot read, update or delete them. Revoking the key or revoking wallet sessions stops its future matches. Subscribe to the existing `trade_proposal.sent` and `trade_proposal.received` webhooks to learn about matches without a search loop. Add `trades` to read, accept, decline or cancel proposals and build settlement.

Create a want with `create_standing_want` (REST `POST /orders/wants`):

```json
{
  "client_order_id": "48400000-0000-4000-8000-000000000001",
  "catalog_card_id": "YOUR_CATALOG_CARD_UUID",
  "min_grade": 9,
  "max_value_micro_usdc": "150000000",
  "max_usdc_add_micro_usdc": "25000000",
  "value_tolerance_pct": 10
}
```

`get_collector_inventory` returns `asset_id` and `catalog_card_id` for each public card, so those IDs can be reused for orders. `catalog_card_id` is the canonical `card_catalog.id` UUID, not a mint or free-text label. `grade` is an optional exact numeric grade; `min_grade` is an optional lower bound. Use one or neither (0–10, up to two decimal places). `max_value_micro_usdc` caps the requested card's reference USD value in integer micros. `max_usdc_add_micro_usdc` authorizes an optional USDC contribution and defaults to `"0"`. Both are capped at 1,000,000 USD/USDC. `value_tolerance_pct` is an integer 0–100, default 10. An omitted expiry is 30 days; an explicit ISO `expires_at` must be in the future and at most 90 days after original creation. `status` is `active` (default) or `paused`.

Create a have with `create_standing_have` (REST `POST /orders/haves`):

```json
{
  "client_order_id": "48400000-0000-4000-8000-000000000002",
  "asset_id": "YOUR_OWNED_ASSET_UUID",
  "min_value_micro_usdc": "100000000"
}
```

One have identifies one owned asset. Optional `min_value_micro_usdc` is the minimum reference value of what its owner receives; omission/null adds no minimum. Haves share wants' status/expiry defaults. They must be public, open to trade, from an approved source, settleable, and neither listed nor escrowed/reserved. Ownership, tradeable status, escrow/reservation holds and chain listing state are rechecked when matching, for both the requested card and reciprocal cards. Temporary escrow, pending listing and buyback holds keep the have active and defer matching for at least one minute via match_after; matching retries automatically after the hold clears. Other reversible eligibility changes also retry. Only a gone/redeemed asset or changed ownership pauses a have. The owning key sees status="paused" with pause_reason="asset_gone" or "ownership_changed" in list_standing_haves / GET /orders/haves; replace it with a new client UUID if the card later becomes eligible again. A failed chain read skips the attempt without pausing the have. Public inventory and field privacy are reapplied when matching. Hidden prices prevent matching; hidden grades prevent grade-constrained matches. Inventory privacy is never changed by posting an order.

Creates return HTTP 201 `{ "order": {...}, "next_step": null }`. Reusing the same `client_order_id` UUID and identical parsed input returns the existing order with HTTP 200; changed input or a deleted order returns 409 `idempotency_conflict`. Keep that UUID when retrying across MCP and REST. These calls do not use action `Idempotency-Key` or produce signable attempts. Up to 100 undeleted wants and 100 undeleted haves are allowed per key; delete expired orders to reclaim capacity.

Update wants using `update_standing_want` with `order_id` plus changed fields (REST PATCH body omits `order_id`). Set `grade`/`min_grade` to null to clear. Expiry extensions stay within 90 days of original creation. Haves have create/list/delete operations; replace a paused have with a new client UUID to activate it. Delete tools take `order_id`. Lists accept optional `after` UUID and `limit` (default 25, maximum 100), returning `orders` or `matches` plus `next_after`; feed that into `after` until the page is empty. Lists include paused/expired orders but omit deleted ones. They are paged reads, not a chronological event cursor. Deletes leave existing proposals intact; cancel a pending proposal separately if needed.

Matching runs after create/update and every minute. A card-for-card match requires reciprocal active wants and haves from the same two keys: each user receives a card they asked for. One card per side is supported. The lower-valued side may add USDC up to its want's explicit maximum. Remaining imbalance must fit **both** wants' tolerance, measured against what each side gives, and both have minimums must be met. Values follow `find_trade_matches` / `value_check`: insured first, market fallback only when insured is null, integer micro-USDC arithmetic, no catalog-price writeback. Unknown/private/zero values are skipped. A want with enough explicit USDC authority can instead pay the full visible card value, without a reciprocal card want.

A match creates a normal unsigned proposal through the shared proposal service. Consent, mutes, active accounts, key scopes/epochs/attestations, the agent kill switch, emergency stops and the hourly match cap still apply. Worker key checks are read-only: they do not consume either participant's 30/minute request budget or change the key's last-used timestamp. Matching reserves no spend and moves no assets or funds. Every later acceptance/settlement retains per-key spend caps, WA/age checks and wallet signatures. Site inboxes display these as ordinary proposals; REST/MCP inbox responses and webhook `data` include `origin: "standing_order"` (`"manual"` for other proposals). No new webhook event name is introduced: the sender receives `trade_proposal.sent`, the recipient `trade_proposal.received`, with existing subscriptions/retries/signatures.

`list_order_matches` returns `{ "matches": [{ "id", "want_id", "have_id", "proposal_id", "status", "origin", "created_at" }], "next_after": ... }`. Reciprocal trades produce two match records referring to one proposal. Concurrent workers cannot create another open proposal for either want/have pair, including workers running in opposite directions. Pending and accepted proposals remain open for deduplication; after a terminal status a new proposal can be matched while the orders remain active. Delete/pause intent when it is fulfilled.

Automatic proposals are capped at **20 per rolling hour per participating key**, counting each proposal once even for reciprocal matches; founders can set integer platform config `agent_order_hourly_cap` (0–1000, default 20). The worker checks the hourly cap before planning or other per-key work and rechecks it under the key locks before claiming a proposal. Capped keys' wants and haves are deferred until enough counted proposals leave the rolling window (a zero cap is rechecked after one hour). Worker batches rotate through active wants and candidate haves with bounded work; minute scheduling is not a guaranteed one-minute delivery SLA. Reconcile after outages through the normal proposal inbox. Creating/updating orders requires trading enabled; listing/deleting remains available with the kill switch off. Client order requests remain limited to 30/minute/key, shared with other agent operations; background matching does not consume that request bucket.


## Wallet event feed

`list_events` / `GET /api/agent/v1/events` returns the same `{id,type,created_at,data}` objects delivered by webhooks, whether or not your wallet has a webhook. It also includes the public CC market stream described below. Any active key can read its wallet's events, including private gift receipts and sibling-key outcomes; other wallets cannot read them. It shares the 30 requests/minute/key limit, needs no action idempotency key, and advances the same bounded attempt reconciliation as `get_me` without reserving spend or submitting transactions.

```json
{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"list_events","arguments":{"limit":100}}}
```

REST: `GET /api/agent/v1/events?limit=100&types=gift.received,attempt.confirmed`. MCP takes `types` as an array. Omit `types` and `filters` for wallet events only. `limit` defaults to 25 and must be 1–100. The response is `{ "events": [...], "next_cursor": "OPAQUE", "next_step": null }`. Pass `next_cursor` as `since` on the next call, including after an empty page. Cursors are specific to this wallet and this feed; proposal cursors and ISO dates are not accepted. Use one cursor per stable types/filter set; omitting `since` replays wallet history but starts market events at the current watermark. V1 wallet cursors also start market events from now. Drain full pages before waiting for the next scheduled poll.

Events are returned in monotonically increasing publication order, newest last. Cursors are assigned only to committed events, so a slow producer transaction cannot commit behind an already consumed cursor. `created_at` records observation time; a late commit can have an earlier timestamp. Concurrent/retried reads may return the same page; deduplicate by event `id`. Wallet retention is **at least seven days**; the retry cron prunes older events and their deliveries in batches of at most 500 events. Events survive webhook deletion. No historical backfill is performed for completed gifts, sales, attempts, proposals or limit decisions. The CC scanner emits each unseen funded bid once per offer account and owner. Its cutoff is the later of its recorded watermark and seven days ago, so open bids up to seven days old still emit `offer.cc_bid_received` at an owner’s first scan; older bids do not.

| Event | When recorded / data |
| --- | --- |
| `offer.cc_bid_received` | Existing enabled CC offer scanner verifies funding, expiry and current ownership; `offer_account`, `mint`, `bidder_wallet`, `price_micro_usdc`, `expires_at` (Unix seconds or null). Includes bids below the bell notification floor. Detection follows the scanner's bounded rotation, not a streaming SLA. |
| `listing.sold` | Seller-sale proof commits; `listing_id`, `mint`, `buyer_wallet`, `price_micro_usdc`, `tx_signature`. Unlisting or unproven departure does not emit. |
| `gift.received` | Gift settlement commits ownership transfer; `trade_id`, `offer_id`, `asset_ids`, `sender_wallet`, `tx_signature`. Private gifts are visible only in the recipient wallet's event stream. |
| `attempt.confirmed`, `attempt.failed` | A reserved build/submit attempt reaches a terminal state, including expiry/reconciliation; `attempt_id`, `key_id`, `action`, `status`, `tx_signature`. Confirmation can also mean a successful action with no transaction, as in the attempt API. |
| `auto_accept.rejected` | A failed rule evaluation is recorded; `proposal_id`, `key_id`, `reason_code`. Deduplicated per proposal/key/rule version/proposal version/reason; a rule or proposal change can emit again. |
| `key.scope_missing` | A call is refused for a missing scope; `key_id`, `scope`, `reason_code: "scope_denied"`. At most once per key/scope per rolling hour across workers. Dry-run refusals do not persist scope events. |

Existing `trade_proposal.*` and `agent.limit_request.approved/denied/expired` payload fields and meanings remain unchanged. Their feed events are recorded while `agent_api_enabled` is off, but no webhook deliveries are queued for those events, including through the legacy fallback. Re-enabling does not replay events observed while disabled. Transient/infrastructure auto-accept failures remain retry evaluations and do not emit `auto_accept.rejected`. `order.matched` remains reserved and its explicit subscription is rejected with HTTP 400. `price.alert` is now the filtered public market event form. Events contain references and outcome fields; fetch the referenced resource for full current state. They never contain signing secrets or unsigned transaction bytes.

## Real-time CC listing events

`market.listing.removed` reports a sale or delist with `reason: "sold" | "delisted" | "unknown"`, `price_usdc: null`, and `previous_price_usdc` when known. Stop chasing that listing; a later relist produces `created`. Select `market.listing.removed` explicitly in webhook `events` or polling `types`. Existing explicit arrays, `all`, filters alone, and `price.alert` alone do not opt into removals. Filters with an explicit removal type return `market.listing.removed`; price thresholds (including price/insured percentage) evaluate the previous price while the emitted active price stays null. If an index removal with reason `unknown` wins, a later chain sale/delist stays suppressed and does not rewrite or duplicate the event. An update without a known previous price emits `created`; equal-price updates emit nothing. Retries of the same signature/mint/event type emit once.

Subscribe with `create_webhook` using `events: ["market.listing.created", "market.listing.price_changed", "market.listing.removed"]`, or poll those `types` with `list_events`. Chain notifications normally arrive seconds after confirmation; the consecutive complete listed-index crawl diff is a minutes-scale fallback. Neither source moves funds or pays bot SOL fees. Re-read `get_card` before buying.

For matching deals, use `events: ["price.alert"]` with a nonempty `filters` object, for example `{"max_price_usdc":100,"max_price_to_insured_pct":80,"name_contains":"Charizard","franchise":"Pokemon","min_grade":9,"grader":"PSA"}`. The same filters work in `list_events` (MCP object; REST URL-encoded JSON `filters`). Filters require market-only event selections and deliver `price.alert` with `data.listing_event_type`; `all` and wallet types cannot be combined with filters. All filters are ANDed and bounds are inclusive; text matching ignores case, name is a literal substring, franchise/grader are exact matches, and missing facts do not match.

Payloads include mint, listing account, seller, name, set/franchise, grade/grader, `price_usdc`, `insured_value_usdc`, `insured_value_as_of`, `price_to_insured_pct`, listing URL, `observed_at`, `source` (`chain`/`index`) and signature when known. Price changes also include `previous_price_usdc` (null before any prior observation). Chain changes include increases; index changes include drops only. A chain/index pair at the same mint/listing/price is one event, with the first observation retained.

Market retention is up to **48 hours or the latest 10,000 events**, independent of wallet retention. Market webhook admission and attempts each cap at **120/minute/endpoint**; global fan-out caps at **100 endpoints/event**, with at most 50,000 market delivery rows. Excess remains available through `list_events`. Polling bots can use a 3–5 second interval within the general 30 calls/minute/key budget, independent of the trade-write budget. Keep filters and types stable for the lifetime of a cursor and dedupe by event ID. Market polling scans at most 500 positions per call, so save the cursor even after empty/short pages; more matches may follow. Market-only polling preserves the wallet position. Polling without types or filters returns only wallet events. Market polling requires explicit market.listing.created/market.listing.price_changed types or filters. Use one cursor per stable types/filter set. A wallet-only poll advances the market position, so alternating wallet-only and market polls on one cursor skips market events. Agent/platform kill switches suppress market ingestion, polling and delivery. Existing explicit subscriptions and wallet payloads remain unchanged. Setup, full schema, null semantics and reliability limits: [listing event details](listing-events-509.md).

## Instant notifications (webhooks)

New registrations default to `events: ["all"]`, covering the existing wallet/trade event set. Market events require explicit `market.listing.created`, `market.listing.price_changed`, or filtered `price.alert` opt-in; `all` never includes them. An explicit event array subscribes only to those types; there are no prefix wildcards. Existing webhooks retain their stored arrays, even if those arrays listed every previously available event. They receive new types only after the owner registers a replacement with an explicit expanded list. Rotation and re-enabling preserve the selection. This avoids surprising strict legacy consumers with new payloads. New deliveries share the event `id` with the polling feed and across endpoints; signing still uses that ID as `webhook-id`.

Register an HTTPS endpoint with a key authorized for `webhooks:manage`. Existing keys do not gain this scope; issue a newly signed key including it. Webhooks belong to the key's user, including users who also trade on the website. Any key for that user with this scope can manage them. There can be at most **three active webhooks per user**. Revoke/delete an endpoint explicitly when you no longer want delivery; key revocation alone does not delete the user's subscriptions.

```sh
curl https://www.grailswap.io/api/agent/v1/webhooks \
  -H "Authorization: Bearer $GRAILSWAP_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"url":"https://bot.example.com/grailswap","events":["all"]}'
```

The `201` response is `{ "webhook": { "id": "UUID", "url": "https://bot.example.com/grailswap", "events": [...], "status": "active", "filters": null, "auth_header_set": false, "consecutive_failures": 0, "disabled_reason": null, "last_success_at": null, "created_at": "ISO", "updated_at": "ISO" }, "secret": "whsec_BASE64", "next_step": null }`. Store `secret` securely: it appears only on create and rotation, never in lists. The endpoint must use HTTPS on port 443 with no URL credentials. Every DNS address must be public; delivery retains every vetted address for dual-stack fallback without resolving again, checks TLS against the original hostname, and follows no redirects.

| Operation | REST | MCP |
| --- | --- | --- |
| Register | `POST /api/agent/v1/webhooks` with `url`, optional `events`, `filters`, `auth_header` | `create_webhook` |
| Set/replace/clear Authorization | `PATCH /api/agent/v1/webhooks/{id}` with optional `auth_header` | `update_webhook` |
| List | `GET /api/agent/v1/webhooks` | `list_webhooks` |
| Delete and discard queued deliveries | `DELETE /api/agent/v1/webhooks/{id}` | `delete_webhook` |
| Synchronous signed ping | `POST /api/agent/v1/webhooks/{id}/test` | `test_webhook` |
| Rotate secret | `POST /api/agent/v1/webhooks/{id}/rotate-secret` | `rotate_webhook_secret` |
| Re-enable and reset failures | `POST /api/agent/v1/webhooks/{id}/enable` | REST only |

If your receiver needs an Authorization header (Cursor, Zapier, n8n), pass `auth_header`; we send it on every delivery next to the HMAC signature. Always still verify the signature. Supply the **full value**, e.g. `"Bearer abc"`, containing 1–1024 printable ASCII characters (spaces allowed, no CR/LF, tabs or Unicode). Only Authorization is configurable; signature, ID, timestamp and content-length headers remain server-controlled. It is sent only to the registered URL; redirects are never followed, including same-origin redirects.

`auth_header` is write-only and encrypted at rest. Create, list, update, enable and rotate responses expose only `auth_header_set: boolean`; neither the value nor its ciphertext is returned. Use `update_webhook` with `{ "webhook_id": "UUID", "auth_header": "Bearer replacement" }` (REST PATCH omits `webhook_id` from the body). `null` clears it, omission or an empty REST PATCH body leaves it unchanged. Update only changes this header, not URL/events/status. Signing-secret rotation preserves the header. Queued retries use the current value; an already in-flight request can use the previous value. `test_webhook` sends the configured header immediately. Delivery records and errors never retain it or receiver response bodies.

If header storage is unavailable (including before migration 0144), requests that set it return retryable HTTP 503 `webhook_auth_unavailable`. Ask the operator to restore configuration, then retry. If a stored header cannot be decrypted, delivery and test stop with `webhook_auth_unavailable` or `webhook_auth_decryption_failed`; the header is never silently omitted. Queued deliveries retry after five minutes without consuming attempts, increasing endpoint failure counters or disabling the endpoint. Deliveries expire one day after creation. Restore configuration or set a replacement value and test; pending deliveries resume automatically within that lifetime. Operator key rotation is documented in [Webhook Authorization](webhook-auth-512.md#key-rotation).

MCP operations taking an ID use `{ "webhook_id": "UUID" }`. All operations require `webhooks:manage` and share the 30 requests/minute/key limiter. They do not reserve spend or require an action idempotency key. A lost create/rotate response cannot recover the secret: list your endpoints, then rotate. A test returns `{ "status_code": 204, "latency_ms": 12, "error": null, "next_step": null }`; a timeout/network failure has null status code and a fixed error code. Tests send `type: "ping"`, `data: {}` and do not alter retry counters. Disabled endpoints can be tested before re-enabling. Enabling does not revive deliveries whose retries are already exhausted. `webhook_limit` is HTTP 409; invalid/non-public URLs return HTTP 400 `invalid_webhook_url`; non-owned IDs return HTTP 404 `not_found`.

Subscribe to any of `trade_proposal.received`, `trade_proposal.sent`, `trade_proposal.accepted`, `trade_proposal.declined`, `trade_proposal.cancelled`, `trade_proposal.expired`, `trade_proposal.settled`, `trade_proposal.failed`. Creation sends `received` to the recipient and `sent` to the sender; subsequent events go to subscribed endpoints of both parties. Acceptance fires once the trade/escrow scaffold succeeds. `settled` means settlement confirmed; `failed` means settlement failed; unsigned settlement cancellation/expiry sends `cancelled`/`expired`. Expiry events follow the five-minute cleanup sweep. Public proposals without a recipient do not emit these events.

Deliveries contain **IDs and lifecycle metadata only**, with no cards, amounts, wallets, names or message text. Fetch details with `get_trade_proposal` (or the REST detail endpoint) using a key with `trades` scope. The detail may already reflect a newer transition.

```http
POST /grailswap HTTP/1.1
Content-Type: application/json
User-Agent: GrailSwap-Webhooks/1
webhook-id: 48200000-0000-4000-8000-000000000001
webhook-timestamp: 1792972800
webhook-signature: v1,<base64 HMAC-SHA256>

{"type":"trade_proposal.received","id":"48200000-0000-4000-8000-000000000001","created_at":"2026-10-26T00:00:00.000000Z","data":{"proposal_id":"48200000-0000-4000-8000-000000000010","status":"pending","origin":"manual","updated_at":"2026-10-26T00:00:00.000000Z","role":"recipient"}}
```

Signing follows [Standard Webhooks](https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md). Base64-decode the secret after removing `whsec_`, then compute HMAC-SHA256 over `webhook-id + "." + webhook-timestamp + "." + raw request body`. The timestamp is Unix seconds for this delivery attempt. Verify the original body bytes **before JSON parsing**, reject timestamps outside five minutes, and compare signatures in constant time. Copy-paste Node verification (`headers` is a Fetch `Headers`, `rawBody` is a Buffer):

```js
import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyWebhook(rawBody, headers, secret) {
  const id = headers.get("webhook-id");
  const ts = headers.get("webhook-timestamp") ?? "";
  if (!id || !/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > 300)
    throw new Error("Invalid webhook timestamp");
  if (!secret.startsWith("whsec_")) throw new Error("Invalid webhook secret");
  const expected = createHmac("sha256", Buffer.from(secret.slice(6), "base64"))
    .update(`${id}.${ts}.`).update(rawBody).digest();
  const valid = (headers.get("webhook-signature") ?? "").split(/\s+/).some(part => {
    const [version, value] = part.split(",");
    if (version !== "v1" || !value) return false;
    const supplied = Buffer.from(value, "base64");
    return supplied.length === expected.length && timingSafeEqual(supplied, expected);
  });
  if (!valid) throw new Error("Invalid webhook signature");
  return JSON.parse(rawBody.toString("utf8"));
}
```

Python verification (`raw_body` is bytes; `headers` is a case-insensitive HTTP header mapping):

```python
import base64, hashlib, hmac, json, time

def verify_webhook(raw_body, headers, secret):
    delivery_id = headers.get("webhook-id")
    ts = headers.get("webhook-timestamp", "")
    if not delivery_id or not ts.isascii() or not ts.isdigit() or abs(time.time() - int(ts)) > 300:
        raise ValueError("Invalid webhook timestamp")
    if not secret.startswith("whsec_"):
        raise ValueError("Invalid webhook secret")
    key = base64.b64decode(secret[6:], validate=True)
    expected = hmac.new(key, f"{delivery_id}.{ts}.".encode() + raw_body, hashlib.sha256).digest()
    valid = False
    for part in headers.get("webhook-signature", "").split():
        version, sep, value = part.partition(",")
        if version != "v1" or not sep:
            continue
        try:
            supplied = base64.b64decode(value, validate=True)
        except ValueError:
            continue
        valid |= hmac.compare_digest(supplied, expected)
    if not valid:
        raise ValueError("Invalid webhook signature")
    return json.loads(raw_body)
```

Persist `webhook-id` as a unique key alongside your processing work and acknowledge duplicates with 2xx. Return 2xx promptly after durably recording the event; process it asynchronously. Delivery is at least once and ordering is not guaranteed. An acknowledgement lost after your server processes an event can cause a retry with the same ID and payload. Retries have fresh timestamps/signatures and use the current secret. Rotation is immediate; an in-flight request may still use the old secret, so keep the previous verification secret briefly during rotation.

The instant path uses post-response `after()`. Enqueue can occur inside an outer transaction: if the instant worker runs before that transaction commits, it sees no delivery yet. The durable pending row becomes visible after commit and the every-minute cron sweep delivers it; a rollback leaves no row to send. Delivery may therefore wait for the next sweep, and retry delays are approximate. Non-2xx responses (including redirects), DNS rejection, TLS/network errors and a five-second total timeout retry after **30 seconds, 2 minutes, 10 minutes, 1 hour, 6 hours and 24 hours**, then become `failed` after the seventh attempt. Claims abandoned for more than two minutes are reclaimed. An endpoint is disabled after **20 consecutive unsuccessful delivery attempts across events**, with one bell notification per disable episode; a successful attempt resets the count. Check the endpoint, test it, and re-enable it to reset the count and resume pending work.

When `agent_api_enabled` is off, delivery and test pings pause and existing queued rows remain pending. Existing proposal emission keeps its enable-switch behavior; cap outcomes and new wallet facts are still recorded. Endpoint management and `list_events` remain available. Enqueue failures are isolated from confirmed money paths, so reconcile source state after service outages or proposal kill-switch gaps. **No server? Poll `list_events` every 30–60 seconds with the saved cursor.**


## Automatic proposal acceptance and sign-ready webhooks

An owner can authorize matching incoming proposals once with `set_auto_accept_rules`.
This reuses the signed key's `trades` scope; no new scope.
Existing keys remain OFF until explicitly enabled. Rules belong only to the authenticated key;
multiple recipient keys are evaluated independently, and only one can claim a proposal.

```json
{
  "enabled": true,
  "counterparties": "bots_only",
  "allowed_wallets": [],
  "max_value_given_micro_usdc": "100000000",
  "max_usdc_paid_micro_usdc": "25000000",
  "min_value_ratio_bps": 9500,
  "require_complete_values": true,
  "only_standing_wants": false,
  "standing_want_id": null,
  "daily_count_cap": 5,
  "daily_value_cap_micro_usdc": "300000000"
}
```

REST: GET/POST `/auto-accept`, POST `/auto-accept/disable`; MCP:
`get_auto_accept_rules`, `set_auto_accept_rules`, `disable_auto_accept_rules`.
Set replaces the entire rule set; omitted fields reset to defaults (disabled and zero caps).
All rule-management calls share the general 30/minute/key limit and auditing. Reads and disable remain available while
trading is disabled. No idempotency key. `counterparties` is `any`, `bots_only` (the existing
Bot account-origin definition), or `allowlist` (exact wallet addresses, maximum 100).

Values use the same insured-first/market-fallback integer micro-USDC math and field privacy
as `value_check`. Here **given** means the recipient's cards plus recipient USDC; **received**
means proposer cards plus proposer USDC. The ratio excludes fees, matching value_check.
The USDC-paid limit includes the recipient's platform fee as well as its cash leg.
9500 basis points means received/given >= 0.95, compared without floating point or cent rounding.
Unknown given values always prevent acceptance because the value cap cannot be proven.
By default any unknown value prevents acceptance; if require_complete_values=false,
unknown received values count conservatively as zero. Public, open-to-trade, available cards
are required; private or held cards remain pending. Daily count/value limits are rolling 24 hours,
independent of the existing key's USDC caps; both must pass.

`only_standing_wants` checks every incoming card against an active, unexpired want owned by
this same key, including catalog, grade and maximum value. `standing_want_id` optionally narrows
to one want. Standing wants are optional; when they are unavailable the filter
fails closed with `standing_wants_unavailable`. It is safe to use other rules before standing orders are available.

Creation schedules evaluation after the response; the minute webhook cron retries transient
failures. Counterparty, self-trade and value-bound rejections stay terminal for the evaluated
rule/proposal versions; changing rules or updating the proposal makes them eligible again.
Cheap rule checks precede RPC, rate charges and last-used writes. Rules, current value caps, key revocation/session epoch, self-trade, opt-out,
mutes, age/WA attestations, emergency/pause, the enable switch and existing USDC spend caps
are checked before the conditional accept. Background evaluation relies on the key owner's
signed age/WA attestations; rule configuration and sign/submit also enforce request geo.
SOL checks use the existing shared minimum but **automatic** acceptance fails closed on an RPC
error/timeout (`sol_preflight_unavailable`); manual acceptance retains the existing behavior.
Dynamic fees/rent remain build-time checks, so preflight is not a guarantee against later
balance changes or unavailable providers. No model is called and no wallet is signed by the engine.

A failed evaluation leaves the proposal pending. Recipient-only `get_trade_proposal` includes
`auto_accept: {accepted, evaluations:[{key_id,reason_code,checked_at}]}`; the sender never
receives these reasons or rule snapshots. Codes include `values_incomplete`, `max_value_given`,
`max_usdc_paid`, `min_value_ratio`, `daily_auto_count`, `daily_auto_value`, `counterparty_not_allowed`,
`counterparty_not_bot`, `standing_wants_unavailable`, `not_standing_want`, `sender_muted`,
`opted_out`, `key_revoked`, `insufficient_sol`, `list_pending`, `crypt_listed`, `asset_committed`,
`auto_accept_timeout`, and existing rail errors. Local listing and buyback holds are checked
under asset locks; expired unsigned sell attempts are released, while signed/exposed holds remain.
Workers have a 20-second run deadline and an 8-second attempt deadline; webhook delivery starts
independently of automation. Before migration 0140, automation skips quietly and rules endpoints
return `auto_accept_unavailable` (503). Each successful acceptance
atomically logs its key, proposal, rule snapshot and exact values and creates an owner bell notification.
The owner's proposal card shows “Accepted automatically” in all eight locales.

### Webhook → fetch → sign → submit

The existing `trade_proposal.accepted` event (manual or automatic acceptance) remains ID-only.
Its `data` additionally contains a relative `sign_url` and `next_step`:

```json
{
  "sign_url": "/api/agent/v1/proposals/PROPOSAL_UUID/sign-ready",
  "next_step": {
    "tool": "get_sign_ready_settlement",
    "rest": {"method": "POST", "path": "/api/agent/v1/proposals/PROPOSAL_UUID/sign-ready"},
    "args_hint": {"proposal_id": "PROPOSAL_UUID"}
  }
}
```

1. Verify the Standard Webhooks signature, deduplicate webhook-id and acknowledge.
2. Each participant fetches with its own `gsk_` key: POST sign_url with no body, or
   MCP `get_sign_ready_settlement({proposal_id})`. Both require `trades` and all write rails.
3. Sign `transactions[0].unsigned_transaction` locally, preserving every existing signature.
4. Call `submit_signed_transaction` with the returned attempt_id and full signed base64 bytes.

Both sides receive the shared settlement message and independently sign their required slots.
The fetch preserves partner/service signatures, checks SOL first and rebuilds a stale blockhash.
It reuses the key's existing settlement reservation, retains old exposed messages for reconciliation,
and serializes fetch against submit. Concurrent preparation returns `attempt_busy`; retry the fetch.
A submitted attempt returns status guidance; never replace an uncertain signed submission.
`expires_at`/`seconds_remaining` are the signing-session ceiling; Solana's blockhash can expire
sooner. Sign promptly, and re-fetch if it expires. `blockhash` and `last_valid_block_height`
carry the existing RPC validity metadata. Transaction bytes and secrets never appear in webhooks.


## Scopes, caps and rate limits

Authenticated agent tools use the same two per-key buckets through REST and MCP (public reads/discovery have IP limits): **30 calls/60s for reads and management**, and **30 calls/60s for trade writes**. Trade writes include market buy/list/unlist/accept-bid, gifts, offer and proposal create/accept/cancel/decline, settlement, swap execute, pack buy/open/buyback, submit, both sign-ready tools, and auto-accept. MCP also enforces independent pre-authentication IP allowances: 60 general JSON-RPC calls/60s/IP and 60 trade-tool calls/60s/IP. Polling cannot consume the trade budget. Profile updates and swap quotes use the general bucket; profile updates also retain 10/hour. MCP dry runs use the general IP allowance. Dry runs retain their separate 30/minute/key preview budget. Existing wallet-level action limits still apply. A 429 includes `Retry-After`; honor it and retain the same request/idempotency key.

Scopes are `market`, `offers`, `trades`, `swap`, `packs`, `buyback`, `profile:write`, `webhooks:manage` and `orders:manage`; the table above shows each tool's requirement. Existing keys never acquire new scopes automatically. Submit requires the attempt's scope and owning key; status also requires that owning key.

All amounts ending in `_micro_usdc` are **unsigned decimal integer strings** (1 USDC = 1,000,000 micros). Wallets and signatures use Solana base58; serialized transactions use base64. REST requests and responses are JSON, with private/no-store responses.

Caps cover outgoing USDC, including the P2P fee when the agent pays it. Gift settlements also count the gifted cards' captured insured-first value (market fallback), and `accept_cc_bid` counts the gross CC bid value. Other trades exclude NFT values. SOL fees and incoming proceeds are excluded; sale and buyback proceeds do not replenish budget. An unsigned offer or proposal spends zero; accepting/building settlement reserves its eventual spend. Pack batches reserve aggregate cost, so the per-transaction cap bounds the entire batch.

For spend actions whose schema accepts it, supply `max_price_micro_usdc`. That ceiling is reserved before building, then replaced with the actual service amount before transaction bytes are returned. If omitted, the per-transaction cap is reserved. `send_card` accepts the same ceiling for captured card value, including on settlement rebuild. The ceiling must fit both caps; use a tighter ceiling when remaining daily budget is lower. Listing prices and offer/proposal amounts are your requested terms; purchase prices, fees, swap quotes and buyback proceeds are server-derived.

Daily usage includes **every pending hold**, even after expiry or an out-of-band submission, plus confirmations in the last 24 hours. `expires_at` bounds the API signing window (at most ten minutes, shortened by quote/signing deadlines); it does not prove the transaction cannot land. Call `get_me` / `GET /me` to advance reconciliation. The ordinary settlement builder cannot replace an exposed build while its hold remains pending. `get_sign_ready_settlement` can refresh its blockhash within the same hold, retaining every exposed message for reconciliation; it never replaces a submitted attempt.

| Limit | Value |
| --- | --- |
| General authenticated calls (reads, management, profile, swap quote) | 30/60s/key |
| Trade writes (builds, submit, sign-ready, auto-accept) | Separate 30/60s/key |
| MCP pre-authentication general / trade tools | Independent 60/60s/IP each, per server instance |
| Profile updates | 10/hour/key, plus the general limit |
| Key challenge/management requests | 20/minute/IP |
| Key issuance | 10/hour/wallet, after valid unused proof |
| Market buy/list builds and submissions | 12/minute/wallet per route bucket, shared across keys and site calls |
| Market unlist builds and submissions | 40/minute/wallet per recovery route bucket, shared across keys and site calls |
| Pack batch | 1 by default; more than 1 is admin-only; maximum 10 |
| Signed transaction submission | 1–10 distinct indices, 0–9; each base64 string at most 16000 characters |
| Request size | MCP: 180000 UTF-8 bytes; REST: 180000 characters |
| MCP/action route duration | Up to 300 seconds |

Limits are additive. Honor HTTP 429 and any `Retry-After` header or delay in the error message.

## Sign, submit and recover

For wallet-funded bots, use the [TypeScript/Python local signer guide](https://www.grailswap.io/docs/agent-local-signer.md). New recovery and same-owner handoff fields are documented in [llms.txt](https://www.grailswap.io/llms.txt).

Recovery guidance is additive: `sign_and_submit` means sign the returned bytes; `refresh_blockhash` means the originating key should call `get_sign_ready_attempt` / `POST /attempts/{id}/sign-ready` before the original deadline. An invalid hash, unbounded age or at most 32 blocks of remaining expiry margin recommends refresh. CC bids retain exact expiry; market buys and list/unlist use partner bytes with an estimated expiry floor. A matching `getLatestBlockhash` observation also gives exact expiry.

Immediately after the partner returns bytes, before simulations or account work, probe `isBlockhashValid` at finalized commitment and record that bank's block height. A processed-valid and finalized-invalid hash is newer than that bank, assuming the same fork and finalized context <= processed validity context. Its expiry floor is probe finalized height + 150. Binding and persistence retain this floor even if the hash finalizes before bind; binding and polls check processed height against floor - 32. Already-finalized unmatched hashes at the early probe have unknown age and request refresh. Legacy in-flight attempts with no recorded expiry get a single `refresh_blockhash` after deploy; the replacement records its own freshness evidence. `transactions[].blockhash_last_valid_block_height` remains the exact expiry when known, otherwise null/absent; the estimate is never exposed as exact. `last_valid_block_height` is only a reconciliation ceiling.

At 2.4 blocks/s with a 32-block finalized lag, a fresh build stays `sign_and_submit` for about 36 seconds of polling: (probe height + 150 - 32 - processed height) / 2.4 = (150 - 32 - 32) / 2.4 = 35.8s. Build latency consumes this window; ten-second polls at 10/20/30s remain signable and the 40s poll requests refresh. Within 20 seconds of refresh, low margin or unknown age returns `wait` unless the hash is explicitly invalid. The guard is capped at the deadline; `retry_after_s >= 1`, including when `seconds_left` rounds down to zero before expiry.

Fresh build responses reuse binding's margin check without another RPC. Subsequent status observations are cached per attempt for ten seconds in a bounded 1,000-entry memory cache, with concurrent reads shared and changed bytes/validity invalidating the entry. RPC checks retain a three-second total budget. Slow/unknown observations return `wait`, a positive `retry_after_seconds` (also `recovery.retry_after_s`), and a polling `next_step` for `get_attempt_status`.

A same-owner handoff reader receives `recovery.action: "handoff_to_signer"`: hand the unchanged bytes to `handoff.signing_wallet` and the originating API key. That key checks or refreshes the build, the original wallet signs, and the originating key submits. The reader gets no refresh URL/tool; its `next_step.message` explains the handoff before polling. This applies to attempt GET, market sign-ready and proposal sign-ready. It never instructs a handoff reader to refresh or submit directly. Submitted work retains polling advice.

Expiry is checked before `tx_mismatch` advice: an expired attempt follows normal reconciliation/new-key rules. For an unexpired attempt with a recorded signed copy, `retry_same_key` means resubmit that identical recorded signed transaction without re-signing or refreshing. If no signed copy was recorded (for example, settlement refreshed before submission), `sign_and_submit` and `next_step` direct you to fetch the current sign-ready transaction, then sign it. The error message, `next_step_text` and executable `next_step` agree.

Inspect the decoded transaction locally, including wallet, recipients, mints, amounts and network. Sign its exact message with the matching local wallet; do not modify instructions, fees or blockhash. The agent API intentionally requires exact messages, including for wallets that normally rewrite transaction instructions.

`POST /submit` requires bearer auth and accepts:

```json
{
  "attempt_id": "UUID",
  "transactions": [ { "index": 0, "signed_transaction": "BASE64_SIGNED_TRANSACTION" } ]
}
```

It verifies the key owns the attempt, the attempt's scope, and a valid ed25519 signature for that wallet on the original message. Never retry with different signed bytes. Batches may submit one index at a time (recommended) or several. Completed indices are not resubmitted. Scope, revocation, location and kill switch are checked again at submission. The response is the same attempt envelope; transaction `result` includes the service's chain signature/status. A partially signed P2P settlement remains pending until the other party signs. The second participant calls `build_trade_settlement` for the accepted `offer_id` using their own key and wallet. No separate human approval from the agent owner is required. Buyback confirmation can remain pending while evidence arrives.

After a timeout, `GET /attempts/{attempt_id}` returns the stored envelope for that key, including the original bytes and confirmed indices; it does not relay or reconcile. If the build response itself was lost, repeat its POST with the same `Idempotency-Key` and body to recover the attempt. A still-building or failed attempt returns `attempt_busy`/`attempt_failed`. An expired, unsubmitted attempt returns `attempt_expired` (410), while its reservation remains counted until reconciliation resolves it. Retry `/submit` with identical signed bytes to run the same confirm path again. If an interrupted submission keeps returning `attempt_busy`, wait at least six minutes before retrying the same signed bytes. Never buy a replacement pack to recover an uncertain payment.

After a pack payment is confirmed, POST `/packs/open` with its memo. Repeat reveal using that same memo if the partner reports `payment_confirming` or `reveal_in_progress`. The shared service never purchases again to reveal a paid pack.

### Signing recipes by wallet

**We need the full signed transaction bytes (base64), not a signature or transaction ID. The fee payer's signature is already in slot 0 on pack/buyback transactions and must survive.** Review the message before signing and verify that both the message and slot 0 are unchanged afterward. A fee-payer signature is also the chain transaction ID, but seeing that string alone does not prove a transaction landed.

#### Local keypair (`@solana/web3.js`)

With `kp` loaded securely in your local signer and `attempt` taken from the build response, this co-signs a pack or buyback without replacing the existing fee-payer signature. `callTool` below is your initialized MCP client's tool-call helper; include the bearer header on its transport.

```js
import { VersionedTransaction } from "@solana/web3.js";
const tx = VersionedTransaction.deserialize(Buffer.from(attempt.transactions[0].unsigned_transaction, "base64"));
const feePayerSignature = Buffer.from(tx.signatures[0]);
const message = Buffer.from(tx.message.serialize());
tx.sign([kp]);
if (!feePayerSignature.equals(Buffer.from(tx.signatures[0]))) throw new Error("Slot 0 changed");
if (!message.equals(Buffer.from(tx.message.serialize()))) throw new Error("Message changed");
const signed_transaction = Buffer.from(tx.serialize()).toString("base64");
await callTool("submit_signed_transaction", {
  attempt_id: attempt.attempt_id, transactions: [{ index: 0, signed_transaction }],
});
```

#### Phantom embedded / Phantom MCP

Do **not** use Phantom MCP `solana_send` for this flow. In the observed session it returned the fee-payer signature while the transaction never landed. `solana_sign` signs messages only; it is suitable for the key challenge, not transaction co-signing. That session's message-signature output was **base64url labelled as base58**. If your output has that encoding, convert it and verify it against the exact challenge message before key issuance:

```js
import bs58 from "bs58";
import nacl from "tweetnacl";
const bytes = Buffer.from(phantomMessageSignature, "base64url");
if (bytes.length !== 64 || !nacl.sign.detached.verify(
  new TextEncoder().encode(challenge.message), bytes, bs58.decode(walletAddress)
)) throw new Error("Challenge signature did not verify");
const signature = bs58.encode(bytes); // Send this with the unchanged intent and nonce.
```

The working transaction path in that session was the Phantom SDK `signAndSendTransaction` with the agent session. It **broadcasts the transaction itself**. Obtain the same full signed bytes from your wallet integration, verify slot 0 is unchanged (and the message/blockhash are identical), then pass those bytes to `submit_signed_transaction`. If it already landed, buyback submission can return `transactions[0].result: {"ok":true,"via":"chain","recorded":true}`: GrailSwap found the transaction on chain and recorded it. This is expected and safe; do not sign again or start another payout. The session's sign-only SDK call failed with `submissionConfig required`. If your supported integration exposes only a transaction ID, it does not provide the bytes needed for this flow: use a signer that does. Internal KMS interception is not a supported integration recipe.

#### Other providers

For Privy, Turnkey, Crossmint, Coinbase CDP or Solana Agent Kit, use the provider's sign-only API if available; if it only offers sign-and-send, confirm it preserves existing signatures and exposes the same full signed bytes for submission.

### Pack and buyback flow

This shortened transcript follows the observed $25 pack → $30 insured card → $25.50 buyback session. Prices, odds and eligibility can change; use the current responses. Identifiers, keys, timestamps and bytes below are placeholders; abbreviated addresses are not valid inputs. MCP responses shown are selected fields from `result.structuredContent`; requests after key setup are tool names and arguments. Use the connection/initialization steps above.

1. **Challenge → key.** After the human approves `packs` and `buyback` with a $50 per-transaction / $100 daily cap, send this intent to `POST https://www.grailswap.io/api/agent/v1/keys/challenge`:

   ```json
   {"wallet_address":"YOUR_SOLANA_PUBLIC_KEY","label":"Pack agent","scopes":["packs","buyback"],"per_tx_cap_micro_usdc":"50000000","daily_cap_micro_usdc":"100000000","attested_age_18":true,"attested_not_wa":true}
   ```

   The response supplies `nonce`, `message` and `expires_at`. Sign the exact UTF-8 message, then POST the same intent plus `"nonce":"RETURNED_NONCE"` and `"signature":"BASE58_MESSAGE_SIGNATURE"` to `https://www.grailswap.io/api/agent/v1/keys`. Securely save the returned `key` and send `Authorization: Bearer <GRAILSWAP_AGENT_KEY>` on subsequent calls. Never send or print a wallet private key.

2. **`list_packs {}` → `buy_pack`.** Choose a currently available pack from `packs`, using `price_usd`, `displayed_odds` and the machine code from the `machine` query parameter in its `url`. The session's `pokemon_25` cost $25, with common/uncommon/rare/epic odds of 75%/20%/4%/1%. Buy one:

   ```json
   {"name":"buy_pack","arguments":{"packType":"pokemon_25","count":1,"max_price_micro_usdc":"25000000","idempotency_key":"pack-buy-example-0001"}}
   ```

   Selected build fields:

   ```json
   {"attempt_id":"BUY_ATTEMPT_UUID","status":"pending","amount_micro_usdc":"25000000","expires_at":"RETURNED_DEADLINE","transactions":[{"index":0,"unsigned_transaction":"BASE64_PAYMENT_TX","reference":"RETURNED_PACK_MEMO","blockhash":"RETURNED_BLOCKHASH","last_valid_block_height":439580739}],"result":{"priceUsd":25,"memo":"RETURNED_PACK_MEMO"}}
   ```

3. **Sign → submit.** Review the 25 USDC payment and use a wallet recipe above, preserving the fee-payer signature. Submit before `expires_at` and blockhash expiry:

   ```json
   {"name":"submit_signed_transaction","arguments":{"attempt_id":"BUY_ATTEMPT_UUID","transactions":[{"index":0,"signed_transaction":"BASE64_SIGNED_PAYMENT_TX"}]}}
   ```

   Continue once the attempt is `confirmed` and `transactions[0].confirmed` is `true`; its `result` can contain `{"ok":true,"signature":"4sCr…"}`. A wallet returning a signature without a confirmed payment is not sufficient to open a pack.

4. **`open_pack`.** Use the returned memo, with a distinct operation key:

   ```json
   {"name":"open_pack","arguments":{"memo":"RETURNED_PACK_MEMO","idempotency_key":"pack-open-example-0001"}}
   ```

   No wallet signature is needed. The attempt's `result.reveal` includes `pullId`, `memo`, `nftAddress`, `name`, `rarity` and `nftWon`. This session revealed a 2024 Pikachu EX PSA 9 Japanese card, `rarity: "uncommon"`, asset `5vxW…Qn`, with $30 insured value. **Pack cards are Metaplex Core assets; they do not appear in SPL token-account queries.** Use the reveal's `nftAddress` and Core asset ownership when checking your card.

5. **`buyback_sell`.** Follow the human's buyback preference. This call quotes, reserves and builds together; review the returned amount before signing:

   ```json
   {"name":"buyback_sell","arguments":{"nftAddress":"REVEALED_CORE_ASSET_ADDRESS","idempotency_key":"pack-buyback-example-0001"}}
   ```

   Selected response fields:

   ```json
   {"attempt_id":"BUYBACK_ATTEMPT_UUID","status":"pending","amount_micro_usdc":"0","expires_at":"RETURNED_DEADLINE","transactions":[{"index":0,"unsigned_transaction":"BASE64_BUYBACK_TX","reference":"REVEALED_CORE_ASSET_ADDRESS","blockhash":"RETURNED_BLOCKHASH","last_valid_block_height":439581714}],"result":{"refundAmount":25500000,"refundUsd":25.5,"quoteId":"QUOTE_UUID","pullId":"PULL_UUID","memo":"RETURNED_PACK_MEMO"}}
   ```

   `amount_micro_usdc` counts **spend**, so it is `"0"` on this sale. Proceeds are in **`result.refundAmount`**, a number in micro-USDC: 25,500,000 = 25.50 USDC. Here it was 85% of the card's $30 insured value, not 85% of the $25 pack price. The quote is authoritative; this example does not promise a future rate. Review that the transaction transfers your Core asset out and 25.50 USDC to your wallet; the partner pays fees in this example. A buyback is a separate sale.

6. **Sign → submit → `get_attempt_status`.** Co-sign the buyback transaction, preserving slot 0, and submit the same full bytes:

   ```json
   {"name":"submit_signed_transaction","arguments":{"attempt_id":"BUYBACK_ATTEMPT_UUID","transactions":[{"index":0,"signed_transaction":"BASE64_SIGNED_BUYBACK_TX"}]}}
   ```

   With Phantom's already-broadcast transaction, the observed result was:

   ```json
   {"status":"confirmed","transactions":[{"index":0,"confirmed":true,"result":{"ok":true,"via":"chain","recorded":true}}]}
   ```

   Finish with `get_attempt_status {"attempt_id":"BUYBACK_ATTEMPT_UUID"}`. Verify `status: "confirmed"` and `transactions[0].confirmed: true`; check the chain for the USDC receipt and Core ownership transfer if needed. If uncertain, recover this attempt, never create a second payout.

### Idempotency playbook

Use **one idempotency key per operation**, reused verbatim with the same body on every retry, including across MCP and REST. Buying, opening and selling back are three operations with three keys. Non-dry build/action POSTs (excluding `/submit`, webhook management, standing-order management, auto-accept management and sign-ready fetch) require `Idempotency-Key`; MCP uses `idempotency_key` (16–128 letters, numbers, underscores or hyphens). `get_me`, `get_attempt_status`, `list_events`, `list_trade_proposals`, `get_trade_proposal`, `submit_signed_transaction`, webhook, standing-order and auto-accept management tools, `get_sign_ready_settlement`, and key setup/management do not take action idempotency keys. Changed input under an existing key returns `idempotency_conflict`. Before repeating **any payment or payout**, check `get_attempt_status` and the on-chain signature status; after a lost build response, replay the original build with the original key to recover its attempt. Retry submit only with identical signed bytes. Some wallets broadcast during signing, so `via: "chain"` is expected. Never buy a second pack to fix a payment; expiry alone does not release its hold, and `get_me` advances reconciliation.

## Freshness guards and simulation

`buy_listing` (`market/buy`), `trade_bot_swap` (`swap/execute`) and `accept_trade_proposal` (`proposals/accept`) accept:

- `max_staleness_seconds`: integer 1–86400, default **120**. This bounds the age of our live insured-value fetch, labeled `as_of_basis: "fetched"`. CC metadata age never causes refusal; `source_updated_at` reports it separately. Unknown, future-dated or expired fetch timestamps fail closed.
- `max_pct_over_insured`: nonnegative percentage, up to six decimal places; omitted means **no percentage cap**.
- Existing `max_price_micro_usdc`: integer micro-USDC spend ceiling. It still applies independently of the percentage guard.

The server reads CC live before building. `stale_value` refuses missing/nonpositive insured values or unknown, future-dated or expired fetch timestamps; `price_above_max` refuses an exceeded spend/percentage bound. Both return `error.current` with available price, insured value, `as_of` and `source`. Successful guarded builds return `result.guard` with these facts even when no percentage was supplied. Trade percentage checks compare given cards plus outgoing cash against received cards plus incoming cash, using fresh insured values for each card. The cash maximum bounds outgoing USDC, including applicable platform fees. Non-CC cards whose live insured value cannot be verified fail closed. A receive side with zero insured value plus cash returns non-retryable `no_receive_value`; choose different terms.

Price/value fields carry `value_provenance[field] = { as_of, source, as_of_basis? }` in their containing object, alongside existing explicit `insured_value_as_of` / `insured_value_source` fields. This covers inventory, trade match/preview, proposal values, Trade Bot quotes/results, auto-accept policies, standing orders and send/cap accounting. `as_of_basis: "fetched"` identifies when we fetched a CC value and does not claim the valuation changed then. Each money field retains its own timestamp, source and basis; listing prices do not inherit insured-value provenance. CC metadata update dates are informational only in `source_updated_at` on guard observations and `insured_value_source_updated_at` on card reads. CC card reads also expose `insured_value_as_of_basis`. The basis is omitted for stored or unknown observations. A null timestamp means freshness is unknown; stored snapshots and policy limits are not live valuation quotes. Retry hint arguments are unchanged. Market search adds `listing_index_as_of` and `stale: true` if its index exceeds target freshness; the build always checks live terms.

Set `dry_run: true` on `accept_cc_bid`, `buy_listing`, `list_card`, `unlist_card`, `trade_bot_swap`, `create_trade_proposal`, `accept_trade_proposal`, `send_card`, `make_offer`, `accept_offer` or `build_trade_settlement`. The REST routes accept the same parameter. A dry run validates the request and builds through the shared transaction compiler, then uses Solana `simulateTransaction` with signature verification disabled. It never signs or broadcasts. Omit the action idempotency key; if provided it is ignored and unconsumed. No audit, last-used update, DB rate-limit counter, reservation, attempt, proposal/offer/escrow row or other DB write is made. The in-memory `agent-dry-run:<key id>` throttle applies (30/minute/key). CC bid previews also use a separate 12/minute/wallet bucket per instance across keys and mints; dry runs do not consume human market counters.

```json
{"name":"buy_listing","arguments":{"mint":"CARD_MINT","max_price_micro_usdc":"100000000","max_staleness_seconds":120,"max_pct_over_insured":5,"dry_run":true}}
```

CC bid previews retain their verified `simulation: "succeeded"`, quote/fee/rent and `cap_impact.reserved: false` response described above. Other preview responses include `dry_run: true`, decoded `effects`, `fee_payer`, `signers_required`, `network_fee_lamports`, per-wallet `sol` balances/needs/sufficiency, `simulation` errors/logs/compute units, `cap_impact` (zero reserved), `guard`, and `warnings`. Unknown RPC fee/need values remain null. Simulation failures return `simulation.success: false`; do not treat HTTP 200 alone as execution readiness. Early validation/guard failures use the error envelope. No signable bytes or attempt IDs are returned. Creation previews simulate the eventual settlement; normal proposal/offer creation itself has no on-chain transaction. Shared wallet caps are checked, but no lock or hold is acquired, so concurrent execution can change the result.

**Limits:** `buy_pack`, `open_pack` and `buyback_sell` accept the flag and return `dry_run_unavailable` before calling reserving/fulfilling partner operations. CC exposes no non-reserving builder for these operations; `open_pack` fulfills a paid pull and has no unsigned transaction. Use `get_pack_summary`, `list_pack_contents` and `get_buyback_quote` for read-only estimates. Transactions requiring a new on-chain lookup table return `dry_run_requires_lookup_table`; a dry run cannot fund/provision that table. Existing usable tables can be read. Cancellation/decline and profile tools do not advertise transaction simulation.

After reviewing a simulation, repeat with `dry_run: false` and a new idempotency key, then sign the returned transaction locally. All prices, caps and availability are rechecked. This feature adds no SOL/fee sponsorship and changes no signer or custody model.

## Errors

`rpc_retry_required` is a retryable 503 with `Retry-After: 1`. Try again after the delay; check the existing attempt before rebuilding or signing, and retain the idempotency key. It can occur during a build or either sign-ready call. An RPC failure does not prove that a submitted transaction failed.

Every REST/tool failure uses `{ "error": { "code": "scope_denied", "message": "…", "retryable": false, "next_step_text": "Ask the owner for a key containing the required scope." } }`. Optional `current` contains known live price, insured value, observation time/source, listing status, SOL balance/requirement or remaining cap. No missing values are invented. MCP sets `isError: true` and returns the same JSON in text and `structuredContent`. Protocol-level malformed JSON-RPC remains a protocol error.

Legacy codes and detail fields remain stable, including `listing_price_changed`, `listing_gone`, `listing_changed`, `price_above_max`, and `purchase_region_blocked`. Regional `region`/`kind` move inside `error`. `error.next_step` retains its executable object shape (`tool`, `args_hint`, plus existing `rest`/deadline/message fields when present). Human-readable guidance is additive under `error.next_step_text`; there is no separate `next_action` object. Successful attempt hints keep their existing shape.

### Changes in 505a

The envelope adds `retryable`, `next_step_text`, and optional observed `current` facts while preserving executable `error.next_step.tool` / `args_hint`. MCP tool failures return `isError: true` with identical JSON text and `structuredContent`. Unknown tool names still return JSON-RPC `-32602`; bad arguments for a known tool use `invalid_input` tool errors. Discovery `not_found` / `private` results retain their previous top-level `status`, `url`, and `is_bot` (where previously present), alongside the error envelope.

For agent buy maximum refusals, `price_above_max` replaces the former `slippage_exceeded` code; other spend checks may still use `slippage_exceeded`. Agent error responses map chain `price_mismatch` to `listing_price_changed`; human purchase records continue storing `failureReason: "price_mismatch"` for PriceMismatch/6021. Neither settlement transitions nor fee calculations change.

These are existing agent and delegated-service codes. **Retryable** describes recovery after checking status and the chain, not permission to repeat a payment. A failed build may require reconciliation before a genuinely new operation; replaying its key cannot turn a failed attempt into a new build. Unknown errors should stop signing until you understand the response.

| Code | Cause | Retryable? | What to do |
| --- | --- | --- | --- |
| `invalid_input` | Invalid fields/JSON, unknown properties or oversized body. | After correction | Use the tool schema; reduce batch size. |
| `invalid_body` | Expected a JSON body. | After correction | Send valid JSON with the correct content type. |
| `bad_json` | Proposal body is not valid JSON. | After correction | Correct the body. |
| `invalid_id` | Identifier is not a UUID. | After correction | Use the returned resource UUID. |
| `idempotency_required` | Missing or malformed action key. | After correction | Supply 16–128 letters, numbers, underscores or hyphens. |
| `missing_idempotency_key` | Shared acceptance handler requires an operation key. | After correction | Supply the action key; retain it on retries. |
| `idempotency_conflict` | Key reused with different operation/input. | Original input only | Replay the original operation and body. |
| `idempotency_in_flight` | Acceptance with this key is still running. | Later | Wait and recover the same operation. |
| `expected_signed_bytes` | A signature was submitted instead of the full signed transaction bytes (the message names our slot-0 signature when it matches). | After correction | Submit the full signed transaction as base64, preserving existing signatures; retry the same attempt. |
| `fee_payer_signature_missing` | The wallet stripped or replaced the built transaction's slot-0 (fee payer) signature. | After correction | Co-sign the original bytes with a wallet that preserves existing signatures, then retry the same attempt. |
| `tx_mismatch` | Message/signature mismatch or changed bytes on retry. | Check expiry and recorded bytes | If unexpired, resubmit identical recorded signed bytes; if none were recorded, fetch and sign the current sign-ready transaction. Expired attempts reconcile first. |
| `unauthorized` | Unknown, revoked or session-invalidated key. | With valid auth | Use the owning key; obtain new wallet proof if invalidated. |
| `invalid_signature` | Key-challenge signature did not verify. | After correction | Verify encoding and sign the exact UTF-8 challenge with the account wallet. |
| `invalid_nonce` | Challenge expired, used or changed. | Fresh challenge | Request a new challenge and sign its exact message. |
| `agent_api_disabled` | Agent trading is disabled. | After enablement | Wait; inspect existing attempts and budget. |
| `scope_denied` | Required scope is absent. | With authorized scope | Ask the human for the scope and issue an appropriately scoped key. |
| `attestation_required` | Age/WA attestations are not both true. | Only if eligible | Get accurate human confirmations; never fabricate them. |
| `purchase_region_blocked` | Purchases are restricted in Washington. | No while restricted | Stop the purchase; do not bypass location checks. |
| `per_tx_cap` | Outgoing spend exceeds the key's per-transaction cap. | Within cap | Review spend and use a fitting ceiling; changing caps requires human authority. |
| `daily_cap` | Spend plus pending holds exceeds the rolling budget. | After budget clears | Read `get_me`; wait for reconciliation or rolling spend to clear. |
| `account_daily_cap` | Spend plus pending holds across the wallet exceeds its shared rolling cap. | After budget clears or approval | Wait or use `request_limit_increase`; issuing more keys does not increase the cap. |
| `active_key_limit_requires_approval` | An agent-made account already has an active key without linked-owner cap approval. | After owner approval | Use `link_owner`, then `request_limit_increase` and the owner's signed Settings approval. |
| `yolo_admin_only` | Multiple packs require admin access. | With one pack | Use `count: 1`. |
| `account_suspended` | An account is not permitted to trade. | No while suspended | Stop and resolve account eligibility. |
| `forbidden` | Wrong participant/owner or invalid settings-page origin. | After correction | Check ownership and the proper session/origin for key management. |
| `beta_not_invited` | This wallet is not eligible for access. | After eligibility changes | Use an invited account; do not bypass the gate. |
| `invalid_cursor` | Proposal `since` is neither a valid opaque cursor nor an ISO timestamp (400). | After correction | Reuse the last successful `next_cursor`, or restart from a known ISO timestamp. |
| `not_found` | Resource absent or inaccessible to this key/wallet. | After correction | Check identifier and owning key. |
| `slippage_exceeded` | Server-derived amount exceeds the spend ceiling. | After review | Review current terms and budget before a new operation. |
| `attempt_busy` | Build/submission is in progress or has no signable data yet. | Later | Read status; if an interrupted submit stays busy, wait at least six minutes and retry identical bytes. |
| `attempt_conflict` | Attempt changed or another settlement reservation exists. | After recovery | Recover the existing attempt; reconcile before rebuilding. |
| `attempt_failed` | Build failed or was replaced. | Not this build | Check the current attempt and chain before deciding whether a new operation is safe. |
| `not_signable` | Action has no transaction. | No submission | Read the immediate result; do not call submit for it. |
| `pack_spend_mismatch` | Pack transaction failed spend/layout checks. | After service correction | Do not sign; wait for a valid build. |
| `attempt_expired` | API signing window elapsed. | Not these bytes through the API | Check chain and `get_me` reconciliation before rebuilding; expiry alone does not release budget. |
| `expired` | Market quote or proposal expired. | New terms only | Reconcile any payment first; refresh the listing or ask for a new proposal. |
| `stale_quote` | Trade Bot quote changed. | Fresh quote | Review a new quote before executing. |
| `rate_limited` | Request budget exceeded. | Later | Honor `Retry-After`, `retryAfter` or the message's delay. |
| `maintenance` | Site maintenance. | Later | Wait and honor the response delay. |
| `feature_unavailable` | Feature disabled. | After enablement | Wait for that feature to return. |
| `trading_paused` | Trading is paused. | After resumption | Wait; retain existing attempt identifiers. |
| `unavailable` | Service or evidence temporarily unavailable. | Later | Check status and recover the same operation. |
| `audit_unavailable` | Audit persistence unavailable. | Later | Check status before retrying; the action may already have run. |
| `invalid_price` | No usable server pack price. | After service recovery | Do not invent a price; rediscover and reconcile the failed build. |
| `invalid_expiry` | Build expiry could not be established. | After service recovery | Do not sign without a valid deadline. |
| `username_locked` | Username is permanent. | Avatar only | Keep the name; update only the avatar. |
| `username_taken` | Name already claimed. | Different name | Choose another name. |
| `username_has_spaces` | Username contains whitespace/invisible characters. | After correction | Remove prohibited characters. |
| `username_not_allowed` | Username failed moderation. | Different name | Choose an allowed name. |
| `not_paid` | Pack payment has not been recorded. | Recover payment first | your payment tx isn't on chain; if your wallet returned signature[0] that's GrailSwap's fee-payer sig — submit signed bytes before expires_at; never buy a second pack to fix it |
| `payment_confirming` | Collector Crypt is still observing payment. | Same memo | Wait, then reveal the same paid pack. |
| `reveal_in_progress` | Another reveal is running. | Same memo | Honor the delay and retry the same memo; never purchase again. |
| `payment_uncertain` | Relay failed and chain outcome is uncertain. | Same bytes only | Check attempt and chain; retry identical bytes while valid. Never replace the purchase. |
| `cc_error` | Collector Crypt rejected or could not fulfill the request. | Depends on message | Check status/chain; retry reveal with the same memo. Review permanent rejection before signing. |
| `cc_gacha_failed` | Pack/payment/reveal/buyback service failed. | After recovery | Recover the existing payment or reveal; never pay again to reveal. |
| `cc_gacha_unconfigured` | Packs are not configured. | After enablement | Wait for pack availability. |
| `cc_gacha_admin_only` | Packs are restricted to admins. | After eligibility changes | Wait for public availability. |
| `cc_gacha_preview` | Only pack preview is enabled. | After enablement | Do not attempt real purchases in preview mode. |
| `pack_disabled` | Selected machine is disabled. | After enablement | Rediscover available packs; recover any paid pack separately. |
| `cc_insufficient_usdc` | Pack simulation found insufficient USDC. | After funding | Fund the same wallet; check prior attempts before a new purchase. |
| `cc_no_usdc_account` | Wallet has no USDC token account. | After funding | Fund the wallet with Solana USDC and recheck budget. |
| `cc_simulation_failed` | Pack transaction simulation failed. | After recovery | Do not sign; inspect the response and wait or correct funding. |
| `buyback_unavailable` | Card is outside the window, not held, listed or being redeemed. | Only if eligibility changes | Check current ownership/window; do not force a sale. |
| `BUYBACK_PENDING` | Existing sell-back is still finishing. | Later | Recover that buyback and check chain before another payout. |
| `gift_disabled` | Gift-pack reveal is disabled. | After enablement | Wait, retaining the gift's original memo. |
| `legal_acceptance_required` | Gift reveal requires current terms/age acceptance. | After human acceptance | Have the owner complete the site's legal acceptance. |
| `verification_failed` | Market purchase transaction could not be verified. | After correction | Do not sign altered transactions; refresh facts and recover the attempt. |
| `listing_price_changed` | HTTP 409: live ask differs from the expected price; includes current and expected prices when known. | New terms only | Refresh and review the current price before a new purchase. |
| `price_above_max` | HTTP 409: live Market ask exceeds your maximum; includes current and maximum prices. | New terms only | Do not blindly retry or automatically raise the ceiling. |
| `listing_changed` | HTTP 409: ambiguous receipt/generation change; includes current price when known. | New terms only | Refresh the listing, then review price and availability. |
| `price_mismatch` | Seller changed the price. | New terms only | Review the new price and cap before a new purchase. |
| `listing_gone` | HTTP 409: card is no longer listed; includes `current_owner` when CC returns it. | No for that listing | Refresh and choose an available listing. |
| `listing_unsupported` | Listing format is unsupported. | No here | Use a supported listing or its original marketplace. |
| `already_owned` | Wallet already owns the card. | No purchase | Check inventory. |
| `already_sold` | Card was already bought. | No purchase | Check the earlier attempt and inventory. |
| `paused` | Market purchases are paused. | After resumption | Wait before building a purchase. |
| `purchase_open` | Another purchase is open for this wallet. | After recovery | Finish or reconcile it before buying again. |
| `pending` | Purchase outcome is still being checked. | Recovery only | Check status and chain before another purchase. |
| `insufficient_funds` | Wallet lacks USDC or SOL. | After funding | Review required balances and fund the same wallet. |
| `relay_failed` | Collector Crypt could not complete relay. | After chain check | Recover with identical bytes if valid; never blindly rebuild. |
| `list_invalid` | Invalid listing/price. | After correction | Use a positive USDC price with at most two decimals. |
| `list_paused` | Listing actions are paused. | After resumption | Wait; manage existing listings through supported recovery. |
| `list_unavailable` | Listing service unavailable. | Later | Check the existing attempt before retrying. |
| `list_ineligible` | Card is not an eligible owned verified pNFT. | Eligible card only | Check ownership and supported asset standard. |
| `list_pending` | Listing submission is being checked. | Recovery only | Recover the existing listing attempt. |
| `list_open` | Another listing action remains open. | After recovery | Wait for or recover the current action. |
| `list_verification` | Listing transaction verification failed. | Correct original bytes only | Do not change its message; recover the original attempt. |
| `list_not_found` | Listing attempt missing. | After correction | Check the attempt identifier and wallet. |
| `user_abandoned` | Listing review was cancelled. | After review | Verify no transaction landed before starting another listing. |
| `asset_committed` | Card reserved for another trade/listing/redemption/buyback. | After resolution | Finish or cancel the existing commitment first. |
| `crypt_listed` | Card has an active Market listing. | After unlisting | Confirm listing cancellation before trading or selling back. |
| `crypt_check_failed` | Listing status could not be checked. | Later | Wait for reliable listing/ownership evidence. |
| `ice_prize_locked` | Card is committed as an ice prize. | After resolution | Settle or cancel that round first. |
| `ice_prize_check_failed` | Prize lock could not be checked. | Later | Wait for verification. |
| `offer_not_pending` | Offer already handled. | No repeat acceptance | Read its current state/settlement. |
| `offer_already_open` | Wallet already has an open offer on this trade. | After resolution | Recover or cancel that offer before another. |
| `trade_not_open` | Trade is no longer open. | No for that trade | Refresh trade state. |
| `trade_expired` | Trade expired. | No for that trade | Choose an open trade. |
| `cannot_offer_own` | Offer targets your own trade. | Different trade | Choose another collector's trade. |
| `asset_not_found` | Offered asset missing. | After correction | Refresh inventory and use current IDs. |
| `not_owner` | Card belongs to a different wallet. | Correct owner only | Verify ownership before proceeding. |
| `asset_not_tradeable` | Offered asset is not eligible to trade. | Eligible card only | Use a verified tradeable card. |
| `asset_unavailable` | Card moved or is reserved. | After resolution | Refresh ownership/commitments; recover any pending trade. |
| `asset_not_in_trade` | Requested card is not in that trade. | After correction | Use the trade's actual requested/offered cards. |
| `offer_too_low` | Offer fails the minimum value rule. | Revised terms | Review value and improve the offer within human authority. |
| `offer_unsettleable` | Accepted offer cannot settle. | After correction | Inspect current assets and terms before another acceptance. |
| `unsettleable_trade` | Trade terms/assets cannot settle. | After correction | Use supported assets and valid trade terms. |
| `conflict` | Another request changed the resource. | After state check | Read current status and recover the winning operation. |
| `not_cancellable` | Proposal is not cancellable by this actor/in this state. | No unchanged retry | Check actor and proposal state. |
| `not_declinable` | Proposal is not declinable by this actor/in this state. | No unchanged retry | Check recipient and proposal state. |
| `not_pending` | HTTP 400; proposal already handled. For a participant re-accepting an accepted proposal with a committed trade, `error` includes `proposal_id`, `trade_id`, `offer_id`, optional `escrow_id`, and a `build_trade_settlement` `next_step`. Non-participants receive no IDs. | No repeat acceptance | Follow the rebuild hint for the existing offer, using a new idempotency key. |
| `self` | Proposal targets yourself. | Different recipient | Choose another collector. |
| `empty` | Proposal/swap has no required cards. | After correction | Add the required cards. |
| `too_many` | Proposal exceeds its card limit. | After correction | Reduce card count. |
| `not_friends` | Recipient accepts proposals from friends only. | With permission | Respect recipient preferences. |
| `opted_out` | Recipient disabled direct proposals. | No unsolicited retry | Respect recipient preferences. |
| `asset_missing` | Proposal card is no longer available. | After correction | Refresh inventories and terms. |
| `not_tradeable` | Proposal card is not tradeable. | Eligible card only | Check current card eligibility. |
| `unverified` | Card is not from a verified source. | Verified card only | Choose a supported verified asset. |
| `user_missing` | Recipient account no longer exists. | No for that recipient | Choose a current collector. |
| `accept_failed` | Proposal acceptance failed unexpectedly. | After state check | Check the proposal/attempt before another acceptance. |
| `unsettleable_assets` | Settlement includes unsupported assets. | After correction | Use assets supported by settlement. |
| `insufficient_sol` | Agent accept preflight: HTTP 409, proposal stays pending; `error` contains `party`, `wallet`, `required_lamports`, `balance_lamports`, `shortfall_lamports` and all `shortfalls`. Lamports are decimal integer strings. Build-time status remains 400 (or `result.settlement_error` after committed accept). | After funding | Follow `error.next_step` to fund each short wallet by at least its shortfall, then retry accept with a new idempotency key. A committed accept must rebuild its existing offer instead. |
| `service_unavailable` | Settlement service wallet lacks funds. | Later | Wait for operator recovery. |
| `vault_env` | Trade Bot settlement configuration is invalid. | After service recovery | Wait; do not modify the transaction. |
| `build_failed` | Settlement build failed. | After state check | Recover the attempt; reconcile before rebuilding. |
| `sign_failed` | Settlement signature could not be accepted. | After state check | Check exact message, wallet, expiry and settlement state. |
| `no_rpc` | Settlement RPC is not configured. | After service recovery | Wait for operator recovery. |
| `reconcile_failed` | Settlement confirmation could not be checked. | Later | Retry confirmation through the same signed submission. |
| `trade_bot_coming_soon` | Trade Bot is not public yet. | After enablement | Wait for public availability. |
| `vault_disabled` | Trade Bot vault unavailable/unprovisioned. | After service recovery | Wait; retain existing attempt identifiers. |
| `pokemon_only_tradebot` | Trade Bot currently accepts Pokémon only. | Eligible card only | Choose a supported card or use P2P trading. |
| `vault_sold` | Selected vault card was moved/claimed. | New selection | Refresh the vault and review a fresh quote. |
| `card_missing` | User card is missing. | After correction | Refresh inventory. |
| `ownership` | User card is not owned by the signing wallet. | Correct owner only | Choose an owned card. |
| `card_locked` | User card is reserved/locked. | After resolution | Complete or cancel the other commitment. |
| `give_needs_dual_price` | Swap-in card lacks a second price source. | After evidence changes | Use another card or P2P trading. |
| `cashback_disabled` | Swap cash back disabled. | Different terms | Choose a vault card closer in value. |
| `cashback_needs_dual_price` | Cash back lacks sufficient price evidence. | Different terms | Reduce cash back or choose a closer-value card. |
| `cashback_too_large` | Cash back exceeds per-swap limit. | Different terms | Reduce requested cash back. |
| `cashback_budget` | Daily cash-back budget exhausted. | Later/different terms | Wait or take a closer-value vault card. |
| `vault_low_usdc` | Vault USDC is low or could not be verified. | Later/different terms | Wait or choose terms needing less cash back. |
| `self_swap` | Vault attempted to swap with itself. | Different account | Use the actual collector account. |
| `overlap` | Same card appears on both sides. | After correction | Remove overlapping cards. |
| `too_many_cards` | Swap exceeds card-count limit. | After correction | Reduce card count. |
| `card_too_small` | Card below Trade Bot minimum value. | Different card | Choose an eligible card or P2P trading. |
| `too_large` | Swap exceeds per-swap value limit. | Different terms | Reduce swap value within authorized scope. |
| `daily_limit` | Trade Bot daily swap limit reached. | Later | Wait or use P2P trading. |
| `insufficient_usdc` | Wallet lacks swap top-up USDC. | After funding | Fund the wallet or choose closer-value cards. |
| `execute_failed` | Swap execution failed unexpectedly. | After state check | Check the existing attempt and chain before repeating. |
| `target_value_unavailable` | Trade-match target has no public insured or market value. | With an explicit value | Supply `value_usd` instead of inferring private or unknown values. |

MCP tool failures return HTTP 200 with `result.isError: true`, the REST body in `result.structuredContent` and JSON text, and REST status in `result._meta.http_status`. `_meta.retry_after` preserves an upstream `Retry-After`. Missing/invalid/revoked keys and scopes are tool errors. Malformed JSON-RPC, unknown tools, HTTP/IP limits and transport maintenance retain protocol/HTTP errors.

## Availability

Agent trading is live in production, subject to the operator enable switch. When disabled or unset, action calls and submit return **403 `agent_api_disabled`**. `get_me` and `get_attempt_status` remain available, subject to authentication, rate limits and emergency guards. Availability checks fail closed on errors.

Every action, including profile updates, submit and paid-pack reveal, requires owner attestations of **age 18+** and **not in Washington** and refuses trusted US-WA location. Key setup also checks attestations, location and access eligibility; profile updates check access eligibility. Existing Market, pack, Trade Bot, emergency, ownership, privacy, listing, escrow and account-suspension rules apply. Profile keys with explicit `profile:write` are available now; existing keys retain their scopes.

## Implementation notes

### Endpoint fields and responses

Paths below are relative to `/api/agent/v1`. Full request constraints and response envelopes are in [OpenAPI](https://www.grailswap.io/openapi.json). All action POSTs require bearer auth and `Idempotency-Key`.

| POST path | Scope | Request example / fields | Result |
| --- | --- | --- | --- |
| `/gifts/send` | `trades` | `{ "asset_ids": ["UUID"], "recipient": "@Collector", "message": "Enjoy", "private": true }` | 1–5 distinct owned cards; message up to 140 characters; private defaults false. Optional `max_price_micro_usdc` bounds the gift value reservation. Standard settlement attempt, sender signature only. |
| `/profile/update` | `profile:write` | `{ "username": "SCBot", "avatarUrl": "https://arweave.net/AVATAR_ID" }` | Optional fields; null clears avatar. `result.profile` has `username`, `avatarUrl`, `is_bot`; no transaction. |
| `/market/buy` | `market` | `{ "mint": "NFT_MINT", "max_price_micro_usdc": "50000000" }` | Current Market purchase, one transaction. |
| `/market/list` | `market` | `{ "mint": "NFT_MINT", "price": "50.00" }` | Owner's ask in USDC decimal text, cents only; one listing transaction. Existing server listing fees apply. |
| `/market/unlist` | `market` | `{ "mint": "NFT_MINT" }` | One cancellation transaction. |
| `/market/accept-bid` | `market` | `{ "mint": "NFT_MINT", "offer"?: "OFFER_PDA", "min_price_usd"?: "39.64", "dry_run"?: true }` | Verified sale attempt; dry-run returns effects with the in-memory dry-run throttle and a separate 12/minute/wallet preview bucket per instance, no idempotency key. |
| `/offers/create` | `offers` | `{ "trade_id": "UUID", "offeredAssetIds": ["UUID"], "requestedAssetIds": [], "usdcOffered": 0, "usdcRequested": 0, "message": "Optional", "expiresAt": "2026-11-01T00:00:00Z" }` | Existing P2P offer; `result.offer`; no transaction yet. USDC term fields use the human service's decimal numbers. At least one offered asset or positive USDC is required; omitted requested assets means the whole listing. |
| `/offers/accept` | `offers` | `{ "offer_id": "UUID", "max_price_micro_usdc": "50000000" }` | Accept via the existing maker path, then build settlement. |
| `/offers/cancel` | `offers` | `{ "offer_id": "UUID" }` | Cancel own pending P2P offer; no transaction. |
| `/proposals/create` | `trades` | `{ "recipientWallet": "WALLET", "offeredAssetIds": ["UUID"], "requestedAssetIds": ["UUID"], "proposerUsdc": 0, "recipientUsdc": 0, "message": "Optional" }` | Directed proposal between active users, subject to recipient consent and inventory privacy; `result.id`; no transaction. USDC term fields are decimal numbers with at most six places. |
| `/proposals/accept` | `trades` | `{ "proposal_id": "UUID", "max_price_micro_usdc": "50000000" }` | Read-only SOL preflight, then accept/build. Committed acceptance retains `result.proposal_id`, `trade_id`, `offer_id`, `escrow_id` (legacy camel-case IDs also remain). Build failure is success with `settlement: null`, `settlement_error` and a rebuild hint. |
| `/proposals/decline` | `trades` | `{ "proposal_id": "UUID" }` | Recipient declines; no transaction. |
| `/proposals/cancel` | `trades` | `{ "proposal_id": "UUID" }` | Proposer cancels using existing cancellation checks; no transaction. |
| `/trades/settlement` | `trades` | `{ "offer_id": "UUID", "max_price_micro_usdc": "50000000" }` | Build a participant's transaction for an already accepted offer/proposal. Allows the other agent to sign without a browser. |
| `/swap/quote` | `swap` | `{ "userCardIds": ["UUID"], "vaultCardIds": ["UUID"] }` | Authoritative Trade Bot quote in `result`; save `result.quoteKey`. No transaction. |
| `/swap/execute` | `swap` | `{ "userCardIds": ["UUID"], "vaultCardIds": ["UUID"], "expectedQuote": "64-hex-quoteKey", "waiveCashBack": false, "max_price_micro_usdc": "50000000" }` | Existing Trade Bot execution and settlement build. Vault signs only its own side; the agent signs its wallet's side. |
| `/packs/buy` | `packs` | `{ "packType": "pokemon_50", "count": 1, "turbo": false, "max_price_micro_usdc": "50000000" }` | One transaction and memo per pack; count defaults to 1; count > 1 requires admin. Turbo preserves the existing automatic Common sell-back behavior. |
| `/packs/open` | `packs` | `{ "memo": "MEMO_FROM_PURCHASE" }` | Reveal an owned paid pull; `result.reveal`; no new payment or signing. Use a new request idempotency key to retry a still-pending reveal; keep the same memo. |
| `/packs/buyback` | `buyback` | `{ "nftAddress": "NFT_MINT" }` | Quote and authorize an eligible held pull using the existing buyback reservation, then return its transaction. Refund is server-derived in `result.refundAmount` / `result.refundUsd`. |

All return a common envelope:

```json
{
  "attempt_id": "UUID",
  "status": "pending",
  "amount_micro_usdc": "50000000",
  "expires_at": "2026-10-25T12:03:00.000Z",
  "transactions": [
    { "index": 0, "unsigned_transaction": "BASE64", "reference": "PURCHASE_ID_OR_MEMO", "blockhash": "BASE58_BLOCKHASH", "last_valid_block_height": 123456789, "confirmed": false }
  ],
  "result": { "purchaseId": "UUID", "priceUsdc": 50000000, "expiresAt": "…" }
}
```

`blockhash` and `last_valid_block_height` are present when validity evidence is available. A transaction's optional `result` contains confirmation details such as chain signature/status. Top-level `result` preserves the action's service response. Immediate actions have `status: "confirmed"` and `transactions: []`. Before a build has data, status may return only `attempt_id`, `status`, `amount_micro_usdc` and `expires_at`.

### Key lifecycle

Challenge response: `{ "nonce": "RANDOM_HEX.EXPIRY_MS.HMAC_HEX", "message": "Create a GrailSwap agent API key…", "expires_at": "2026-10-25T12:05:00.000Z" }`. The message includes domain, wallet, nonce, label, scopes, caps, attestations and Terms version. Treat `nonce` as opaque. Server secret rotation can invalidate outstanding challenges; request a new one. Normal sign-in challenges cannot issue keys.

`POST /keys` with the **same fields**, plus `nonce` and the base58 `signature`. Response (201): `{ "key": "gsk_…", "id": "UUID", "wallet_address": "…", "label": "Market scout", "scopes": ["market"], "per_tx_cap_micro_usdc": "50000000", "daily_cap_micro_usdc": "100000000", "created_at": "…", "revoked_at": null, "last_used_at": null }`. The plaintext key is returned only here. Store the returned key securely.

“Log out everywhere” and suspension invalidate older keys at their next authentication. A fresh wallet proof is needed for a new key. Invalidated keys can still have a null `revoked_at`; explicit revocation sets it. Revocation prevents further API access but cannot revoke previously signed bytes on-chain.

- `GET /keys`: owner session required; returns `{ "keys": [keyInfo] }` for the owner's wallet, newest 100, without key hashes or secrets.
- `DELETE /keys/{id}`: owner session and same-origin `Origin` required; revokes only that wallet's key; returns `{ "key": keyInfo }`. Existing signed bytes cannot be revoked on-chain. Revocation prevents further API access.
- `GET /me`: bearer key required; returns `{ "key": keyInfo, "agent_api_enabled": false, "used_micro_usdc": "0", "remaining_micro_usdc": "1000000000" }`. Available while writes are disabled.

### Profile updates

`update_profile` / `POST /api/agent/v1/profile/update` accepts optional `username` and `avatarUrl` only, plus the normal MCP `idempotency_key` or REST `Idempotency-Key` header. Example MCP arguments: `{"username":"SCBot","avatarUrl":"https://arweave.net/AVATAR_ID","idempotency_key":"scbot-profile-000001"}`. The confirmed attempt has `amount_micro_usdc: "0"`, `transactions: []`, and `result.profile` containing `username`, `avatarUrl` and `is_bot`.

The site and agent share the same profile handler: trimmed new usernames, syntax checks, moderation/reserved names, permanent-name locking, case-insensitive uniqueness and concurrent-claim errors. Avatar URLs must be HTTPS and at most 1024 characters; null clears. The site's existing image CSP still controls display. Bio and other profile/account settings are outside this tool's input. Errors preserve the site's codes/messages in the agent error envelope.

`profile:write` is a separate, explicit scope. Issue a new key with it through the signed REST challenge flow; previously issued keys retain exactly their stored scopes. The current settings scope picker lists trading scopes; it can display/revoke profile keys issued via REST. The action enforces the enable switch, valid key, account status, access eligibility, attestations/location and emergency guards, plus 10 calls/hour/key and the general 30/minute/key limit. Calls are idempotent and spend zero USDC.

Profile keys are available now: the `profile:write` scope is live in production. Request it explicitly when issuing a new key.

### Public discovery and account labels

For cards of similar value to trade, call `get_collector_inventory` with `collector` and `near_value_usd` (>0, at most 10,000,000). Optional `value_tolerance_pct` is 1–100, default 20. Matching uses the visible stored insured value, falling back to market value only when insured is null; private and unknown values never match. Values are normalized to integer micro-USDC before inclusive tolerance comparisons. Results sort by stored absolute distance, then asset ID, before cursor pagination; keep the same collector/target/tolerance on subsequent pages. Each card includes `images` and `card_url` (alongside `url`); matching adds `value_delta_usd` as a signed two-decimal string (card minus target) and `value_delta_pct` as a signed number relative to the target, rounded to six decimal places. Example: `{"collector":"SookieSwap","near_value_usd":100}` finds visible $80–$120 cards. Sub-micro targets that normalize to zero return no matches.

`find_trade_matches` accepts exactly one of `mint` or `value_usd`, plus optional `value_tolerance_pct` (1–100, default 20), `franchise`, `min_grade`, `exclude_wallet` and `limit` (1–50, default 12). It excludes the offered card's owner and the Trade Bot vault, and returns public tradeable matches with proposal arguments.

`preview_trade` accepts `give_mints` and `get_mints` (1–20 distinct cards total), optional `give_wallet` / `get_wallet`, and `give_usdc` / `get_usdc` (default 0). Wallet arguments assert sides; they do not authorize private reads. Visible values use insured-first, market-only-when-insured-null pricing. Difference is get minus give; percentage uses give total; exactly ±10% is even. Private/unknown values make affected totals and balance null. Review ownership, eligibility and consent warnings. Direct proposals do not require friendship. Recipients must be active and allow direct offers (default: enabled); a recipient mute returns `blocked`. Hidden-inventory and individually hidden cards cannot be requested (`asset_missing`), but you can offer your own cards with optional USDC. At least one card is still required. `create_trade_proposal` returns the same informational `value_check`; it never blocks a proposal.

The public **Bot** label identifies accounts created through agent key issuance. Classification uses a key created within five seconds of account creation, including revoked keys; adding a key to an older site account does not qualify. Revoking keys does not remove the label. Payload names are REST `isBot` and MCP/agent `is_bot`.

### Pack purchase and REST examples

Pack purchase uses the same flow, followed by reveal:

```json
{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"buy_pack","arguments":{"packType":"pokemon_50","count":1,"turbo":false,"max_price_micro_usdc":"50000000","idempotency_key":"pack-operation-000001"}}}
```

Review/sign the returned pack transaction locally, then call `submit_signed_transaction` with **that pack's** attempt ID and signed bytes as above. After payment confirms, use its transaction `reference` (the pack memo):

```json
{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"open_pack","arguments":{"memo":"PACK_MEMO","idempotency_key":"pack-reveal-000000001"}}}
```

Keep the memo when retrying a pending reveal; use a new reveal idempotency key to obtain a fresh result. `count > 1` is admin-only. `turbo: true` retains automatic Common sell-back. Outcomes are random; packs can be worth less than their price. Opening an already paid pack does not make another payment.

The REST equivalents use the same key, fields, idempotency values, envelopes and signing process:

```http
POST /api/agent/v1/market/buy
Authorization: Bearer <GRAILSWAP_AGENT_KEY>
Idempotency-Key: listing-operation-0001
Content-Type: application/json

{"mint":"NFT_MINT","max_price_micro_usdc":"50000000"}
```

```http
POST /api/agent/v1/submit
Authorization: Bearer <GRAILSWAP_AGENT_KEY>
Content-Type: application/json

{"attempt_id":"ATTEMPT_UUID","transactions":[{"index":0,"signed_transaction":"SIGNED_BASE64"}]}
```

```http
GET /api/agent/v1/attempts/ATTEMPT_UUID
Authorization: Bearer <GRAILSWAP_AGENT_KEY>
```

```http
POST /api/agent/v1/packs/buy
Authorization: Bearer <GRAILSWAP_AGENT_KEY>
Idempotency-Key: pack-operation-000001
Content-Type: application/json

{"packType":"pokemon_50","count":1,"turbo":false,"max_price_micro_usdc":"50000000"}
```

After signing/submitting the pack payment via `/submit`:

```http
POST /api/agent/v1/packs/open
Authorization: Bearer <GRAILSWAP_AGENT_KEY>
Idempotency-Key: pack-reveal-000000001
Content-Type: application/json

{"memo":"PACK_MEMO"}
```

### Spending and transaction validation

Reconciliation advances bounded batches (at most two attempts per call), so repeated `/me` calls may be needed. There is no scheduled reconciliation guarantee. Pending holds remain until finalized history and action outcomes resolve uncertainty. Failed exact signatures or missing status alone do not prove no payment landed; changed transactions or out-of-band broadcasts can still count. Missing/pruned evidence and service failures preserve holds. Known successful outcomes count at least the reserved amount; larger decoded outgoing USDC can increase usage. Matching related activity can conservatively overcount.

Partially signed P2P settlements remain held even before the other party signs. Batches retain their full reservation until every item resolves; if any item landed, at least the full batch amount counts for 24 hours from reconciliation. Confirmation starts the 24-hour window when observed, so delayed discovery cannot shorten it. Failed/expired builds that never exposed bytes release budget. Budget reconciliation does not itself complete a pending pack reveal or other service recovery.

Before returning a pack transaction, the agent adapter decodes every SPL token transfer, pins its USDC mint and buyer authority/source, and verifies the exact aggregate amount against the service price, reservation and `max_price_micro_usdc`. It uses the same configured USDC mint as human Market paths, rejects all top-level System instructions (including SOL transfers and account-creation drains), and caps ComputeBudget priority fees at 0.001 SOL total. At most one ATA `createIdempotent` is allowed, exclusively for the buyer’s canonical USDC ATA with the buyer paying rent; other owners, mints, payers, duplicate creations and non-idempotent creations are rejected. A missing compute-unit limit uses the conservative 1,400,000-unit maximum. Unsupported transaction layouts (including unresolved lookup tables, indirect token spend and duplicate/unsupported compute-budget instructions) fail closed with `pack_spend_mismatch`.

`result` preserves the shared collector service's response. Immediate actions return `status: "confirmed"`, `transactions: []`. A transaction can already contain the partner/vault's signature; preserve it. The agent's signature is always absent when a new build is created. No service wallet signs for the agent.

MCP supports bearer CORS preflight without credentialed cookies and returns private/no-store responses. Browser cookies do not authorize tools. Credentials belong in HTTP headers, not tool arguments or JSON-RPC metadata. Both transports share the same action behavior; all signing remains in the agent's own wallet.

## Response hints

Agent responses add `next_step`: `{tool, rest: {method, path}, args_hint, deadline?, message?}`, or `null` when done. Errors carry human-readable `error.next_step_text` and, where available, the existing executable `error.next_step` object. Builds point to `submit_signed_transaction` with the attempt ID and signing deadline, and include `seconds_remaining`. Confirmed pack payments point to `open_pack` with the memo; open results offer `get_buyback_quote` for the pulled card. For batches, open every confirmed transaction’s reference (memo). Keeping the card requires no action.

| Outcome | `next_step.tool` | REST path | Action |
|---|---|---|---|
| Re-accept of committed proposal `not_pending` (400) | `build_trade_settlement` | `POST /api/agent/v1/trades/settlement` | Use `error.offer_id` and the preserved spend ceiling from `error.next_step.args_hint`, with a new idempotency key. |
| Accept preflight `insufficient_sol` (409) | `accept_trade_proposal` | `POST /api/agent/v1/proposals/accept` | Fund every wallet listed in `error.shortfalls` by at least `shortfall_lamports`; `message` gives exact SOL. Retry with a new idempotency key. |
| Acceptance committed, build failed (200) | `build_trade_settlement` | `POST /api/agent/v1/trades/settlement` | Resolve `result.settlement_error`, pass the existing `offer_id` and preserved spend ceiling, and use a new idempotency key. |

### Proposal acceptance and settlement recovery

Before committing an agent accept, maker and taker SOL balances are read in parallel through the existing RPC fallback selector. The shared funding helper checks the maker's known minimum (one network signature, flat fee, existing buffer and pNFT allowance) and the contributing taker's flat fee plus existing buffer; a passive recipient requires no SOL. It does not sponsor fees or change fee amounts. This getBalance-only preflight cannot know live priority fees, additional signatures, missing ATA rent or lookup-table reimbursement: the builder uses the same helper with those full costs. Passing preflight is not a guarantee that the later build can settle. The configured flat fee can be zero; no new fee is introduced.

An RPC error or a two-second timeout is logged to console/Sentry and **accept proceeds**. This availability choice avoids refusing trades because a balance read is flaky; the existing build checks and pre-broadcast simulation remain the funding backstop. A successful shortfall read returns 409 before creating any trade, offer or escrow. The cookie/site accept path is unchanged.

Emergency and trading-pause checks run before the SOL preflight; a paused, underfunded wallet receives `trading_paused` (503) without balance reads.

If acceptance commits but settlement construction fails, the response is still HTTP 200 (MCP tool success). In the normal attempt envelope, `status: "confirmed"` means **acceptance**, not on-chain settlement; `transactions: []` and `amount_micro_usdc: "0"`. `result` retains `proposal_id`, `trade_id`, `offer_id`, `escrow_id` when available, `settlement: null`, `settlement_error: {code, message}` and `next_step`. The envelope repeats that `next_step`. Replaying the original idempotency key or reading its attempt returns the saved IDs; do not repeat acceptance. This acceptance-only result books no USDC, so rebuilding reserves its outgoing USDC normally. If you re-accept with a new idempotency key, HTTP 400 `not_pending` (MCP tool error) preserves the committed IDs and the same rebuild hint inside `error` for an authorized participant; follow that hint instead of accepting again.

```json
{
  "status": "confirmed",
  "amount_micro_usdc": "0",
  "transactions": [],
  "result": {
    "proposal_id": "48300000-0000-4000-8000-000000000010",
    "trade_id": "48300000-0000-4000-8000-000000000030",
    "offer_id": "48300000-0000-4000-8000-000000000031",
    "escrow_id": "48300000-0000-4000-8000-000000000032",
    "settlement": null,
    "settlement_error": {"code": "insufficient_sol", "message": "Top up the maker wallet and rebuild the settlement."}
  },
  "next_step": {
    "tool": "build_trade_settlement",
    "rest": {"method": "POST", "path": "/api/agent/v1/trades/settlement"},
    "args_hint": {"offer_id": "48300000-0000-4000-8000-000000000031"}
  }
}
```

Example above omits normal attempt metadata and legacy camel-case IDs. Retry with `POST /api/agent/v1/trades/settlement` and body `{"offer_id":"48300000-0000-4000-8000-000000000031"}`, plus bearer auth and a new `Idempotency-Key`; MCP uses `build_trade_settlement` with the same fields plus `idempotency_key`. Include `max_price_micro_usdc` when supplied in the hint. The retry enforces the same trades scope, spending caps, trading/region/age guards and participant authorization. A successful build returns a pending attempt with signable `transactions` and the normal submit hint; both parties still sign locally.

Each `transactions[]` entry includes `effects`, `fee_payer` and `signers_required`, decoded from the built bytes (including resolved lookup-table accounts). Unknown instructions appear as `{type: "other", program}`. Agents must still decode and verify transactions independently; these hints do not replace signature, program, recipient or amount checks. `amount_micro_usdc` remains outgoing spend; `proceeds_micro_usdc` reports decoded incoming USDC on sales and never replenishes caps.

`get_buyback_quote` is now a read-only estimate, separate from the binding quote/reservation created by `buyback_sell`. It floors insured value × rate into integer micro-USDC, never calls the reserving CC buyback endpoint, and returns `is_estimate: true`. Anonymous eligibility is `null` when ownership needs authentication. `open_pack` adds `result.buyback` with eligibility, rate and estimated refund; unavailable metadata produces null values without failing a successful reveal. Public pack reads share a 30-second source cache; check `top_hits_complete` before treating the hits as the full pack’s ranking.


### Marketplace buy conflicts

REST `POST /api/agent/v1/market/buy`, MCP `buy_listing`, and website buy routes preserve the existing `error: { code, message }` envelope. All listing refusals use **409**, including `listing_gone` (consistent with existing Market conflicts; quote expiry remains 410). MCP text includes the same plain-language message and structured details.

Prices are compared as integer micro-USDC. `current_price_micro_usdc`, `expected_price_micro_usdc` and `max_price_micro_usdc` are integer strings; corresponding `*_price_usdc` fields are display strings rounded to two decimals. Fields appear only when known. The input ceiling remains `max_price_micro_usdc`; `max_price_usdc` is its formatted response value, not a new input. A maximum is not an expected price. Same-price receipt/version or generation changes remain `listing_changed`; malformed/failed reads never imply a new known price. After a refused CC build, a read may enrich the error; it never retries the buy.

Goku (`FHjF5dnjfGNNypVXwycFjE21uHK5Ldn4bU5usshzmmPd`), live CC ask `"50"` and caller maximum $45:

```json
{"error":{"code":"price_above_max","message":"Price is now $50.00 (your maximum is $45.00).","current_price_usdc":"50.00","current_price_micro_usdc":"50000000","max_price_usdc":"45.00","max_price_micro_usdc":"45000000"}}
```

If an expected $45 listing changes to $50 during verification, the code is `listing_price_changed`, with `expected_price_usdc: "45.00"` and `expected_price_micro_usdc: "45000000"`. If the caller also supplied a maximum below the live ask, the agent response uses `price_above_max` and retains the expected fields.

Veluza (`4TMEJ5eUVhx1ZNdb1gapet65QCiKsFAF1Efd16vgR6h9`), CC returns `listing: null` (owner abbreviated here; responses include the full wallet):

```json
{"error":{"code":"listing_gone","message":"This card is no longer listed.","current_owner":"CbjEhE…"}}
```

Refresh the listing and review availability/terms before starting a new attempt with a new idempotency key. Refusals expose no new signable transaction. Existing `agent_actions.request_summary` records `error_code` and known `current_price_usdc` / `current_price_micro_usdc` alongside `http_status`. Other actions retain `slippage_exceeded` for their maximum guards.
