Skip to content

Agent trading API

POST /api/v1/trade/agent/build and POST /api/v1/trade/agent/confirm let an agent buy or sell a token on Solana or Hood (Robinhood Chain), against a market that already exists. For a Candle-launched token (a live bonding curve, or a graduated Solana token routed through Jupiter), every tier can trade it. Pro and Max can additionally trade any Solana token, Candle-launched or not, an arbitrary mint routed straight through Jupiter against a chosen base quote asset — see Tier gating below. This is a separate rail from launching: see docs/headless-launch.md for creating a token in the first place (headless or self-signed) and for Auth, tiers, and Linked wallets, all of which this rail reuses unchanged.

Every value-moving transaction this rail builds carries the same tiered platform fee that delegated headless and self-signed launch dev buys now carry (see Fee legs on launches below): this doc is the one place the fee model, the spend gate, and the enforcement boundary are written up in full. The separate one-shot base-asset rail (POST /api/v1/agent/swap, covered in The base-asset swap rail is free below) is the one exception: it never charges this fee.

Same agent key as everywhere else (x-api-key), but a distinct scope: both endpoints require swap:write, which is opt-in only. Unlike launch:write/launch:read/activity:write, an issued key never gets swap:write by omission — pass it explicitly:

{ "scopes": ["launch:write", "launch:read", "activity:write", "swap:write"] }

to POST /api/v1/agent/keys (see Auth in docs/headless-launch.md). A key issued without it gets SCOPE_MISSING (403) on both endpoints here. The SDK’s buildTrade()/confirmTrade() do not check this client-side; the server is the authoritative gate.

A test-environment key cannot use this rail at all: both endpoints reject it outright with TEST_ENVIRONMENT_FORBIDDEN (403), before any other validation. Unlike headless launches (which narrow a test key to visibility: "test"/"hidden"), a trade has no non-production equivalent to fall back to — every leg this rail can produce moves real value through a live venue.

Tier gating: which tokens each tier can trade

Section titled “Tier gating: which tokens each tier can trade”

Every tier can trade a Candle-launched token: a live bonding curve on either chain, or a graduated Solana token routed through Jupiter (mint resolves to a Candle tokens row). On top of that:

  • Pro and Max can also trade any Solana token, Candle-launched or not. A mint with no Candle tokens row routes through planArbitrarySolanaJupiterTrade: a live Jupiter quote against a chosen base quote asset (quoteAsset: "sol" | "usdc" | "cndl", defaulting to "sol" when omitted), at the same tiered platform fee as any other trade on this rail, charged on the quote asset (see Fee model below).
  • Free and Believer keys cannot trade an arbitrary mint. A Solana mint with no Candle tokens row returns TIER_REQUIRED (403), naming that Pro or Max is required.
  • Arbitrary Hood tokens are not yet supported on this rail, for any tier. A Hood/EVM address (0x-prefixed) with no Candle tokens row always returns MARKET_NOT_FOUND (404), regardless of tier — there is no mint-agnostic Jupiter equivalent on Hood yet, only the Candle-curve builders this route already uses for a known Hood token.

Every trade names a payer, exactly like the payer choice self-signed launches already offer:

  • { "type": "main" } — the account’s own delegated embedded wallet, the same wallet that signs a headless launch. The trade executes immediately, inline, server-side via Privy, the moment /build returns: the response is status: "executed" outright, and there is nothing left to sign, broadcast, or confirm. /confirm is never called for a main-payer trade; calling it anyway is rejected VALIDATION_FAILED.
  • { "type": "linked", "linkedWalletId": "..." } — an imported linked wallet (Pro/Max tier only, see Linked wallets in the headless-launch doc). Candle never signs this trade and never holds its signer key. /build returns status: "built": an unsigned artifact for the agent to sign and broadcast itself, then report to /confirm, which verifies it on-chain before recording anything. Signing goes through the same Candle sign relay self-signed launches use (see “Self-signed launches” in the headless-launch doc): the agent authorizes locally with its own P-256 key, and Candle — authenticating with its own app secret — forwards the already-authorized request to Privy. Both chains work end to end today, for curve-phase trades. The SDK’s CandleClient.trade({ from: { linkedWalletId, privyWalletId } }) runs build, sign (through the relay), and a server-side submit in one call for a Solana-linked payer: the signed transaction goes straight to POST /api/v1/trade/agent/submit (see Server-side submit below), which broadcasts and confirms it through Candle’s own RPC — so a Solana-linked trade() call needs no solanaRpcUrl configured at all. For a Hood-linked payer, the client must configure evmRpcUrl (an EVM JSON-RPC endpoint, separate from solanaRpcUrl) — trade() throws a clear error naming it when unset, before any signing. With it set, trade() assembles each of artifacts.approval (if present), artifacts.trade, and artifacts.feeTransfer (if present) as its own EIP-1559 (type 2) transaction — fetching nonce, chain id, and fee data from evmRpcUrl — and broadcasts them in that exact order, waiting for each leg’s own receipt before assembling the next. This ordering is load-bearing, not a style choice: the trade leg’s gas estimate reverts if it runs before the approval leg is mined, since the on-chain allowance is not yet set. It then calls confirmTrade({ clientTradeId, tradeTxHash, feeTxHash? }). Hood DEX-phase trades (a curve that already graduated to its Uniswap pool) are unchanged: there is still no server-side Hood DEX builder, so both endpoints return MARKET_NOT_TRADABLE for a graduated Hood market, regardless of payer mode. To drive the legs yourself instead of using the one-shot, see Flow below for the manual sequence.
{
"clientTradeId": "my-bot-trade-1",
"mint": "So1anaTokenMint...",
"side": "buy",
"amountRaw": "1000000000",
"payer": { "type": "main" },
"maxSlippageBps": 100
}
FieldRequiredNotes
clientTradeIdyesIdempotency key, unique per account, shared with the matching /confirm call. Same ledger semantics as clientLaunchId (see Idempotency in the headless-launch doc): same id + same body + already confirmed replays the stored result; same id + different body is IDEMPOTENCY_CONFLICT (not retryable); same id, still in flight, is IDEMPOTENCY_CONFLICT (retryable).
mintyesThe token’s mint (Solana) or contract address (Hood). For a Candle-launched token, must already exist as a Candle-tradable market. A Pro/Max key can also name an arbitrary Solana mint with no Candle token row — see Tier gating above; a non-Candle Hood address always MARKET_NOT_FOUND, and a non-Candle Solana mint on a Free/Believer key always TIER_REQUIRED.
sideyes"buy" or "sell".
amountRawyesA positive integer string, in raw units. Buy: quote-asset raw units to spend. Sell: base-token raw units to sell.
payeryes{ "type": "main" } or { "type": "linked", "linkedWalletId": "..." }. See The two payer modes above.
maxSlippageBpsnoInteger, 0-10000. Defaults to 100 (1%).
quoteAssetno"sol" | "usdc" | "cndl". Only meaningful for an arbitrary (non-Candle) Solana mint — see Tier gating above. Defaults to "sol". Ignored for a Candle-launched token, whose quote asset is fixed by its own market.

A main payer, “executed” response (a Believer-tier account paying the Believer rate, 50 bps — see Fee model below for what each of the four tiers actually pays):

{
"success": true,
"status": "executed",
"clientTradeId": "my-bot-trade-1",
"chain": "solana",
"signature": "...",
"fee": { "bps": 50, "feeRaw": "5000000", "treasury": "..." },
"amounts": { "amountRaw": "1000000000", "expectedOutRaw": "...", "minOutRaw": "...", "quoteAsset": "sol" }
}

A linked payer, “built” response (Solana; a Free-tier account paying the Free rate, 100 bps — again see Fee model below for the other three tiers):

{
"success": true,
"status": "built",
"clientTradeId": "my-bot-trade-2",
"chain": "solana",
"artifacts": {
"venue": "curve",
"transactionBase64": "<base64, unsigned>",
"quoteAsset": "sol",
"quoteMint": "So11111111111111111111111111111111111111112",
"quoteDecimals": 9
},
"fee": { "bps": 100, "feeRaw": "10000000", "treasury": "..." },
"expectedOutRaw": "...",
"minOutRaw": "...",
"expiresAt": 1754401800000
}

On Hood, artifacts is calldata legs instead of a signed-once blob, since Hood cannot batch multiple calls into one transaction the way Solana can:

{
"artifacts": {
"venue": "curve",
"trade": { "to": "0x...", "data": "0x...", "value": "0" },
"approval": { "to": "0x...", "data": "0x..." },
"feeTransfer": { "to": "0x...", "data": "0x...", "value": "0" },
"quoteAsset": "usdg",
"quoteDecimals": 18
}
}

approval is present only when the payer’s existing ERC-20 allowance to the curve is insufficient for amountRaw (an ETH-quoted or native-sell curve never needs one). feeTransfer is present only when a fee actually applies (see Fee model below).

2. The agent signs, through the Candle sign relay

Section titled “2. The agent signs, through the Candle sign relay”

Signing a linked wallet needs an app-authenticated call to Privy that an external agent cannot make alone: Privy requires both the agent’s own P-256 authorization signature over the exact artifact AND app-level auth that only Candle’s server secret satisfies. The agent computes its authorization signature locally with its own key (the SDK’s signLinkedTransaction() does this via buildPrivyAuthorizationSignature, packages/sdk/src/authorization-signature.ts), which Candle never sees, and calls the Candle sign relay, POST /api/v1/agent/wallets/:id/sign (apps/api/src/routes/agent.ts, swap:write scope). Candle authenticates the app with its own server secret and forwards that exact, already-authorized request to Privy unchanged: it cannot substitute a different wallet or artifact, since the agent’s own signature covers both, and it never holds the signer key. Candle’s server IS a required participant in this call, though: an agent cannot reach Privy’s wallet-sign RPC directly, since Privy’s app-level gate only accepts Candle’s own server secret. This is still no custody of the signer — Candle relays an already-authorized request, it never generates the signature or receives the P-256 key. The flow:

  • Solana. Authorize and sign artifacts.transactionBase64 via the linked wallet’s own Privy signer quorum (the 1-of-1 quorum registered at import time; see Import flow in the headless-launch doc), through Candle’s relay. This works today: the SDK’s CandleClient.trade({ from: { linkedWalletId, privyWalletId } }) runs this whole step, plus build and a server-side submit, in one call (signLinkedTransaction() + submit() under the hood by default; see Server-side submit below). To drive it by hand instead, broadcast the signed bytes yourself with broadcastSignedTransaction() and report the result to /confirm.
  • Hood. Send artifacts.approval (if present), then artifacts.trade, then artifacts.feeTransfer (if present) — in that exact order, each with any EVM client the agent controls, waiting for each one’s own receipt before sending the next. Hood has no way to bundle these into one atomic call the way Solana’s single transaction can, so the send order is a manual, caller-enforced contract, not something the artifacts themselves encode. This works today too: with evmRpcUrl configured, CandleClient.trade({ from: { linkedWalletId, privyWalletId } }) assembles each leg as an EIP-1559 (type 2) transaction (nonce, gas, and fee data fetched from evmRpcUrl via packages/sdk/src/evm-tx.ts’s helpers), signs it with signLinkedTransaction({ chain: "evm", evmTxParams }), broadcasts it with broadcastSignedTransaction(), and waits for its receipt before assembling the next leg — plus build and confirm, all in one call. To drive it by hand instead, sign each leg with signLinkedTransaction({ chain: "evm", evmTxParams }), broadcast with broadcastSignedTransaction() (or your own EVM client), then call confirmTrade() with the resulting tradeTxHash/feeTxHash.

Candle never signs anything on this path and never holds the signer key — identical posture to self-signed launches (see What Candle never does on this path in the headless-launch doc), which also spells out why Candle’s server is nonetheless a required participant in the signing call itself. Requires privyAppId (a PUBLIC Privy identifier, the same value the app exposes as NEXT_PUBLIC_PRIVY_APP_ID, not a secret) configured on the client, matching the app id the relay’s server authenticates under, and a secretStore holding the agent’s P-256 signer PEM (see The never-plaintext rule and Self-signed launches’ “Never holds the key” in the headless-launch doc for what that means for recovery if the key is lost).

The relay signs only API-built transactions

Section titled “The relay signs only API-built transactions”

The agent’s own P-256 authorization signature proves WHO is asking (the caller genuinely controls this linked wallet’s signer), but not WHAT is being signed — nothing about that signature says the transaction inside the request is one this API ever assembled. The relay (POST /api/v1/agent/wallets/:id/sign) closes that gap itself, ahead of any Privy call: at build time (/build here, or /launch/self/build), Candle computes a canonical hash of each unsigned artifact it hands back and stamps it onto a single-use claim row. At sign time, the relay recomputes the identical hash from the request body and claims a matching, unconsumed, unexpired row scoped to the same account and the same linked wallet (packages/db/convex/agentSignClaims.ts). No matching claim — because the body is hand-rolled, already signed once before, or the claim expired — refuses the request with UNRECOGNIZED_TRANSACTION (403), before Candle ever calls Privy: a stolen or misused agent key cannot get an arbitrary self-authored transaction signed through this relay, no matter how it authorizes the request. This is the compensating control that let Spend-limits Phase A demote the linked wallet’s own Privy policy to a fixed structural backstop instead of the swap-breaking catch-all denies it used to need — see “The wallet policy: a fixed backstop, not per-asset caps” in the headless-launch doc.

Solana hashes the ENTIRE unsigned transaction (sha256 of the raw bytes), since Solana signs a whole transaction and every byte of it is either something Candle assembled or something the signature itself covers — an exact byte-for-byte match is both possible and required.

EVM is narrower by necessity, and this is an accepted, intentional residual, not an oversight. The SDK assembles the full eth_signTransaction body client-side — nonce, gas, chain id, and fee data are all fetched fresh right before signing, so the signed body is never byte-identical to anything Candle built. The build-bound hash for an EVM leg therefore covers ONLY the Candle-controlled fields, { to, data, value } (evmLegSignHash / evmTxSignHash in apps/api/src/lib/sign-hash.ts), never nonce/chain_id/type/gas_limit/fee fields. The practical consequence: within the ONE signature a matching claim authorizes, the relay never pins nonce/chain_id/gas at all — the caller is free to choose any value for those fields and the hash check still matches, since they were never part of what got hashed. And when two separate builds legitimately produce the identical {to,data,value} (e.g. two same-size Hood trades sharing an identical fee-transfer leg, see selectClaimableRow’s own comment in packages/db/convex/lib/agentSignClaimsPolicy.ts), each gets its own single-use claim row, so that identical payload can legitimately be signed more than once, each time with independently chosen nonce/chain_id/gas. Either way, to and value stay fixed no matter what: the recipient and the amount moved can never be redirected or increased by varying only the fields the hash ignores. Every EVM leg this API ever builds sends to a Candle-controlled contract address (a curve, the fee treasury, an ERC-20 for an approval) and value either 0 or the exact declared trade/dev-buy amount, so this residual can shift WHEN or on WHICH chain/nonce a leg lands, never WHERE the funds go or HOW MUCH moves. That is the intended scope of the EVM hash, not a gap to close.

Solana:

{ "clientTradeId": "my-bot-trade-2", "signature": "..." }

Hood:

{ "clientTradeId": "my-bot-trade-3", "tradeTxHash": "0x...", "feeTxHash": "0x..." }

feeTxHash is required whenever the matching /build response carried a nonzero fee (artifacts.feeTransfer was present); omitting it there is refused FEE_LEG_MISSING. See The fee-stripped-confirm refusal below.

Candle verifies, in order: the transaction landed on-chain and did not revert; it was actually signed (Solana) or sent (Hood) by the declared linked wallet (SIGNER_MISMATCH otherwise); it actually moved this trade’s own mint, in the direction the trade’s side claims (Solana: a nonzero, side-matched token balance delta for the payer; Hood: bound to this row’s own curve address via to); and, when a fee applies, that the fee transfer landed too. Only once every check passes does anything get recorded: the activity row (or, on Hood, the same verified-decode path recordHoodSwap already uses for every Hood trade), the ledger row’s confirmed status, and the trade.executed webhook (see Webhooks below). A failed verification records nothing at all.

/confirm is idempotent: confirming an already-confirmed clientTradeId replays the stored "executed" response outright, without touching the chain or Convex again.

The response shape is the same "executed" shape a main payer gets back from /build directly (amounts, fee, signature).

Server-side submit: POST /api/v1/trade/agent/submit

Section titled “Server-side submit: POST /api/v1/trade/agent/submit”

An alternative to steps 2 and 3 above, not a replacement for them — the client-broadcast-then- /confirm round trip stays fully supported on both chains, unchanged. Instead of broadcasting the signed legs itself and reporting the result to /confirm, a linked-payer caller can hand Candle the already-signed bytes directly and let it broadcast and confirm in one call:

{ "clientTradeId": "my-bot-trade-2", "signedTransactions": ["<base64, signed>"] }

signedTransactions is the ordered signed legs, produced by the exact same signing step 2 describes above (through the Candle sign relay): exactly one for Solana; one to three for Hood, in the fixed order approval (only when the build produced one), trade, feeTransfer (only when a fee applies) — the same order step 2’s Hood bullet sends them in. Candle broadcasts the bytes itself, through its own RPC (broadcastSignedSolanaTx/broadcastSignedHoodTx in apps/api/src/routes/trade-agent.ts), classifying a broadcast failure exactly like a main payer’s inline execution does; on Hood, each leg’s own receipt is awaited before the next ever broadcasts, the same ordering requirement the client-broadcast path has. Once every leg lands, /submit confirms inline using the EXACT same verifiers /confirm uses (confirmSolanaTrade/ confirmHoodTrade) — the same on-chain checks, the same fee-leg enforcement (see The fee-stripped-confirm refusal below), and the same "executed" response shape /confirm returns, all in one call instead of two. Like /confirm, /submit is for linked-payer rows only — a main payer already executed inline at /build; calling /submit against a main-payer row is rejected VALIDATION_FAILED. And like /confirm, /submit is idempotent on clientTradeId: an already-confirmed id replays the stored result outright, without broadcasting anything again.

This is no more custody than the client-broadcast path: signedTransactions are bytes the linked wallet already signed through the relay in step 2; Candle only relays them onto the chain and reads back the result. The linked wallet remains the on-chain signer and fee payer either way — Candle never signs anything here, exactly the no-custody posture the rest of this doc describes for every other linked-payer path.

Both chains can call /submit directly, but the SDK’s trade() only defaults to it for a Solana-linked payer (see The two payer modes above). A Hood trade’s approval leg must be mined before the trade leg can even be gas-estimated and signed, so its legs cannot all be signed up front the way a server-side submit needs — trade()’s Hood default therefore stays on the client-broadcast sequence described in step 2 above. A Hood caller who wants server-side submit for an already-approved token (no approval leg needed) can still call submit() — or POST /submit directly — itself.

POST /api/v1/agent/keys also accepts an optional ref (a referral code) alongside environment and scopes. It attributes the calling account to that referrer once, set-once and best-effort, and never blocks key issuance if attribution fails; a referred account then pays 10% less on every platform fee this rail charges, from that key onward. The full referral program surface (dashboards, claims) lands with Phase 2’s agent endpoint.

TierTransaction fee
Free100 bps (1%)
Believer50 bps (0.5%)
Pro25 bps (0.25%)
Max0 bps (free)

Reuses the exact tier resolution the launch dev-buy fee uses (resolveAgentFeeBps in apps/api/src/lib/agent-fee.ts): a live tier of pro pays the Pro rate, and a live tier of max pays the Max rate (0 bps — a Max-tier trade legitimately reports "feeRaw": "0", by design, not a bug). “Believer” is not a separately-evaluated live tier here (see Caps and trust tiers in the headless-launch doc: Believer is a display label frozen at key issuance, never live-evaluated) — a Free-live-tier account still pays the Believer rate if its agent key row’s own daily-launch cap is 20 or higher, which is exactly the cap a Believer-eligible key was issued with. Rates are env-tunable server-side, one env var per tier: AGENT_FEE_BPS_FREE (default 100), AGENT_FEE_BPS_BELIEVER (default 50), AGENT_FEE_BPS_PRO (default 25), and AGENT_FEE_BPS_MAX (default 0), each clamped to 0-1000.

Basis and rounding. The fee is ceil(basis * bps / 10000), integer BigInt math, in the quote asset’s raw units — so any nonzero basis at a nonzero rate always charges at least 1 raw unit; it can never round down to zero. The basis differs by side and, on Hood, by whether the buy crosses the graduation boundary:

  • Buy: the quote-asset amount actually spent — amountRaw on Solana, and on Hood the consumed quote (quoteConsumed, not the submitted amountRaw) whenever the buy crosses the curve’s graduation threshold. A capped-refund Hood buy fills only up to the threshold and refunds the unused principal straight back to the buyer; that refunded portion was never spent, so it is never taxed either. Below the threshold, consumed quote equals amountRaw exactly, so this only ever differs right at the boundary.
  • Sell: the quoted quote-asset amount the seller is expected to receive (expectedOutRaw).
  • A launch’s dev buy (delegated headless or self-signed, either chain): the dev buy’s own amount. See Fee legs on launches below.

On top, not netted out of the quote. The fee is additive to what the trade itself moves, never a slice carved out of the requested/quoted amount before the swap runs: a buy’s total outflow is amountRaw + feeRaw (both a Solana buy’s spend gate, below, and the Hood buy’s two separate transactions reflect this), and a sell’s fee is deducted from the proceeds only after the swap has already been quoted and executed at its full expectedOutRaw — the swap itself is never short-changed to make room for the fee.

Destinations. AGENT_FEE_TREASURY_SOLANA and AGENT_FEE_TREASURY_HOOD, one address per chain, per environment. fee.treasury in every response echoes whichever address is currently configured for that trade’s chain, or null when none is. Leaving a chain’s treasury env var unset is the rollout lever: every trade and every launch dev buy on that chain then silently skips the fee entirely (feeRaw: "0", feeBps: 0, no fee leg built, no approval/feeTransfer artifact) — there is no separate feature flag, and no request-side way to opt out or in.

Send mechanics per chain:

  • Solana. The fee instruction rides inside the same transaction as the swap itself (appended after the swap’s own instructions, same signer, same broadcast). A buy’s fee is a plain transfer from the payer; a sell’s fee draws on the payer’s balance after the swap’s own output has already landed in the same transaction, so the payer never needs to hold the fee amount going in, only end up with at least feeRaw once the swap credits them (which expectedOutRaw, floored by minOutRaw, guarantees). There is no separate fee signature to track for a main payer; for a linked payer, /confirm claims the trade’s own broadcast signature as its (unconditional) fee-reuse guard — see the SDK reference on feeSignature for the exact rule.
  • Hood. The fee is always its own separate transaction (feeTransfer), since Hood cannot batch calls. For a main payer it is sent after the trade’s own receipt has already landed, and is non-fatal: a failed fee transfer never undoes or fails the trade that already executed, it just means the fee did not collect (logged, and feeSignature is absent from the response). For a linked payer, the agent sends it itself as the last of the three legs (see Flow above) and reports its hash as feeTxHash at /confirm, where it is a hard requirement, not best-effort — see the next section.

Because Candle never signs a linked payer’s transaction, /confirm is the only enforcement point for a linked-payer trade’s fee — there is no earlier moment where an agent could be stopped from broadcasting a version of the trade with the fee leg quietly dropped. So when the matching /build row carried a nonzero fee, /confirm refuses to record anything — no activity row, no ledger completion, no webhook — unless the fee actually verifies on-chain:

  • Solana: the same broadcast transaction must contain a transfer to the configured treasury of at least feeRaw, in the trade’s own quote asset.
  • Hood: feeTxHash must be present in the confirm body at all (its absence alone is FEE_LEG_MISSING), and must verify as a real transfer of at least feeRaw to the configured treasury.

A verification failure here returns FEE_LEG_MISSING (402), and is not retryable as the same confirm call: the broadcast transaction itself is what is missing the leg, so only a fresh /build (producing a new artifact with the fee correctly included) and a new broadcast can recover.

Hood fee-transfer hashes are single-use, across both this rail and self-signed launches. One atomic, cross-table Convex claim (agentTrades.claimTradeFeeSignature and headlessLaunches. claimSelfFeeSignature each check the OTHER’s own fee-hash index before claiming) means a genuine Hood fee-transfer receipt can only ever pay for one trade or one launch dev buy, on either surface, never twice and never both. On Solana there is no separate fee-transfer hash to reuse in the first place — the fee always rides inside the same transaction as the trade or the dev buy it pays for — so what Solana’s /confirm guards instead is trade-vs-trade replay of the same broadcast signature: a linked payer’s transaction signature is claimed unconditionally (fee or not) the moment it verifies, so the identical signature can never be cited to confirm a second trade.

The spend gate: buys only, amount plus fee

Section titled “The spend gate: buys only, amount plus fee”

/build checks a buy’s total outflow — amountRaw + feeRaw, using the trade’s own freshly quoted feeRaw, never a stale figure — against the calling key’s own configured spend limit for the quote asset (per-key only since 2026-08-23; see Spend limits in the headless-launch doc), before beginning the idempotency row at all. Exceeding it is SPEND_LIMIT_EXCEEDED (400), naming the cap so the caller can retry with a smaller amountRaw or raise the key’s cap from a session first. This is the operator’s own opt-in ceiling, not a platform-wide one; by default every key is unlimited. Sells are never gated this way — a sell spends the base token being sold, which this mechanism does not cap.

Each agent key carries its own per-asset spend caps — since 2026-08-23 the ONLY spend caps; the account-wide main/linked scopes are retired — set via PUT /api/v1/agent/keys/:prefix/limits (body { "limits": [{ "asset", "maxPerTxRaw" }] | null }). An asset the key does not mention is uncapped for that key. This lets each key run on its own leash without any account-wide setting shared between keys.

The actor rule: only a Privy session (the human owner) may loosen — raise an existing key cap, remove one, or clear the key’s limits back to unlimited. An agent key may only tighten its own limits, or another key’s, never loosen them; a loosening attempt from an agent key returns LOOSEN_REQUIRES_SESSION (403). Targeting a key prefix that is not one of the caller’s own (or does not exist) returns 404; targeting a revoked key returns 409.

Self-serve options on top of the account-level caps docs/headless-launch.md’s Auth section already covers: a display label, a self-set expiration, and a per-key windowed USD transaction limit, plus the usage figures behind it so an owner can see what a key has actually spent before setting one. All four routes below sit on the same keysAuth (Privy session or device token, see Auth in the headless-launch doc) as the existing POST / GET / DELETE /api/v1/agent/keys — there is no x-api-key path into any of them today.

This is a different mechanism from Per-key spend limits above. PUT /api/v1/agent/keys/:prefix/limits caps a single asset’s raw amount on a single transaction, enforced at the pre-build spend gate. The txLimit covered here caps a key’s own cumulative USD-value trading volume over a rolling window (day/week/month) or its whole lifetime, enforced separately at trade build. A key can carry both at once, independently.

Naming and expiration (POST /api/v1/agent/keys)

Section titled “Naming and expiration (POST /api/v1/agent/keys)”

Three new optional fields on the existing issuance body, each echoed back in the response only when set:

FieldNotes
label1-64 characters after trimming; trimmed and validated server-side. With no label, the dashboard falls back to a capability-derived name (“Trader key”, “Launcher key”, …).
expiresInDaysInteger, 1-365. Converted to an absolute expiresAt (now plus N days) at creation and echoed as that absolute value — the day count itself is not stored.
expiresAtAbsolute epoch ms, must be strictly in the future. Mutually exclusive with expiresInDays: setting both is VALIDATION_FAILED.
txLimit{ "usdMicros": <positive integer>, "reset": "never" | "daily" | "weekly" | "monthly" }. See KEY_LIMIT_REACHED below.

Expiration is fixed at creation and cannot be changed afterward. There is no field for it on PATCH and no renewal call: once a key is issued with (or without) an expiresAt, that value is permanent for the rest of that key’s life. To change a key’s expiration, issue a new key and revoke the old one. Omitting both expiresInDays and expiresAt keeps the existing default: no expiry.

Terminal window
curl -X POST "$API_URL/api/v1/agent/keys" \
-H "Authorization: Bearer $SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"label": "Trend bot (prod)",
"expiresInDays": 90,
"txLimit": { "usdMicros": 500000000, "reset": "daily" },
"scopes": ["launch:write", "launch:read", "activity:write", "swap:write"]
}'
{
"success": true,
"key": "cndl_live_...",
"keyPrefix": "abcd1234",
"label": "Trend bot (prod)",
"expiresAt": 1762900000000,
"txLimit": { "usdMicros": 500000000, "reset": "daily" }
}

Renaming and transaction limits (PATCH /api/v1/agent/keys/:prefix)

Section titled “Renaming and transaction limits (PATCH /api/v1/agent/keys/:prefix)”

The body must carry exactly one of { "label" }, { "txLimit" }, or { "clearTxLimit": true } — these are three independent edits, not a partial merge, so sending more than one at once is VALIDATION_FAILED. An unknown prefix and a prefix owned by a different account both report the same 404 ("Key not found") every other owner-bound key route in this API reports, so a prefix guess cannot distinguish “wrong owner” from “doesn’t exist.”

Terminal window
# Rename
curl -X PATCH "$API_URL/api/v1/agent/keys/abcd1234" \
-H "Authorization: Bearer $SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "label": "Trend bot (v2)" }'
# Set a transaction limit
curl -X PATCH "$API_URL/api/v1/agent/keys/abcd1234" \
-H "Authorization: Bearer $SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "txLimit": { "usdMicros": 250000000, "reset": "weekly" } }'
# Clear it
curl -X PATCH "$API_URL/api/v1/agent/keys/abcd1234" \
-H "Authorization: Bearer $SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "clearTxLimit": true }'

Each call succeeds with { "success": true, "label": "..." }, { "success": true, "txLimit": {...} }, or { "success": true, "txLimit": null } respectively.

Agent-actor rule, enforced at the Convex level. apiKeys.setTxLimit distinguishes a session actor (a human owner, via Privy session or a device token) from an agent actor (a future x-api-key-authenticated caller). A session actor may raise, lower, change cadence, or clear a key’s txLimit freely. An agent actor may only tighten the usdMicros value while keeping the key’s existing reset cadence — it may never raise the cap, change reset, or clear the limit; any of those three throws server-side. Today PATCH /api/v1/agent/keys/:prefix is keysAuth-only (session or device token), so nothing exercises the agent-actor path through this route yet — the rule is a Convex-level guarantee already in place, ready for whichever future agent-facing endpoint lets a key tighten its own cap.

Usage (GET /api/v1/agent/keys and GET /api/v1/agent/keys/:prefix/usage)

Section titled “Usage (GET /api/v1/agent/keys and GET /api/v1/agent/keys/:prefix/usage)”

GET /api/v1/agent/keys now merges a usage object onto every row:

{
"success": true,
"keys": [
{
"keyPrefix": "abcd1234",
"label": "Trend bot (prod)",
"usage": {
"todayUsdMicros": 12500000,
"weekUsdMicros": 88000000,
"monthUsdMicros": 340000000,
"lifetimeUsdMicros": 950000000,
"days30UsdMicros": 310000000,
"tradeCount30": 41,
"launchCount30": 2,
"lifetimeLaunches": 6
}
}
],
"usageSince": "2026-08-15"
}

today / week / month are UTC-day, ISO-week (Monday start), and UTC-calendar-month to date; days30 is a rolling trailing 30-day window, distinct from month. A key with no usage yet, or one created before metering started, reports all zeros, never an omitted usage field.

usageSince names when metering itself started: 2026-08-15. There is no backfill. Trading and launch activity from before that date is not represented in any of these numbers, including lifetimeUsdMicros: it is not “zeroed because nothing happened,” it is “not counted because nothing was recorded yet.” A key that was heavily used before this date shows a lifetime total that understates its true history.

GET /api/v1/agent/keys/:prefix/usage?days=N returns a zero-filled daily series for charting, days clamped to 1-90 (default 30):

{
"success": true,
"keyPrefix": "abcd1234",
"days": 30,
"series": [
{ "day": "2026-07-17", "tradeVolumeUsdMicros": 0, "tradeCount": 0, "launchCount": 0 },
{ "day": "2026-08-15", "tradeVolumeUsdMicros": 12500000, "tradeCount": 3, "launchCount": 0 }
]
}

Ascending, oldest first, today last, exactly one entry per calendar day in the window regardless of whether a trade or launch happened that day — a day with no matching row zero-fills rather than being omitted, so the array is always exactly days entries long. An unknown or non-owned prefix gets the same 404 "Key not found" PATCH reports above.

Once a key’s expiresAt passes, every authenticated call using that key — any endpoint, any scope — fails KEY_EXPIRED (401), not the generic UNAUTHORIZED a bad or revoked key gets. It is checked immediately after the key’s hash is verified, so a caller must already prove possession of the real secret before learning “expired” rather than “invalid” — a bare key prefix, which can leak via a display UI, still cannot be used to probe a key’s expiry status. Expiration is fixed at creation (see Naming and expiration above): there is no way to extend or clear it on an existing key. Recovery is always the same: create a new key.

txLimit caps a key’s own cumulative trading volume, valued in USD, over the window its reset names: daily (UTC day), weekly (ISO week, Monday start), monthly (UTC calendar month), or never (the key’s whole lifetime). It is checked once, at POST /api/v1/trade/agent/build, right after the trade’s real quote asset is known: if the key’s usage in the current window plus this trade’s own value would exceed the cap, the build fails KEY_LIMIT_REACHED instead of proceeding:

{
"success": false,
"error": {
"code": "KEY_LIMIT_REACHED",
"message": "This key's transaction volume cap would be exceeded by this trade",
"retryable": true,
"resetsAt": 1755302400000,
"uiHint": "This key hit the transaction limit its owner set. Wait for the window to reset, trade a smaller amount, or raise the limit on the key."
}
}

resetsAt is the epoch ms when the current window rolls over, or null for a never (lifetime) cadence, which has no reset. retryable is true only when resetsAt is non-null: a lifetime cap can never be retried as the same trade, only a smaller amount or a raised limit gets past it.

Say this plainly: the cap is an approximate guardrail, not a hard accounting boundary. Enforcement reads the key’s usage rollup at build time, before the trade has executed; the actual usage record for that trade only lands once the trade completes (a main payer’s inline execution, or a linked payer’s verified /confirm), asynchronously and best-effort. Two consequences follow directly:

  • Concurrent trades on the same key can each pass the check independently and, together, exceed the cap. Two builds that both check the window before either one’s usage has landed both see the same (stale) total and both pass, even though their combined value would have failed.
  • A trade that legitimately passed the check at build time can still push the recorded total over the cap by the time it completes, since nothing re-checks the limit between build and completion.

A sell is valued by its quote-asset proceeds, never the raw token amount being sold: at build time, the plan’s own quoted expectedOutRaw; at Solana /confirm, the decoded actual quote-asset amount that landed, when that is available. This is the same valuation rule the usage numbers above use, so what trips the cap and what appears in GET /keys’s usage object always agree.

What an agent needs to know about how a Candle bonding curve actually holds funds and graduates, learned the hard way by a live agent that monitored the wrong account for an hour.

Where the SOL lives. A curve’s quote liquidity accumulates as wrapped SOL in the pool’s quoteVault token account, never as native SOL on the curve address itself. The curve address (virtualPool on the token row) is a program account whose native balance is just rent and never moves with trading — polling it shows a flatline regardless of volume. The vault’s total is quoteReserve + accumulatedFees: the reserve is what counts toward graduation, the fees (the curve’s own feeBps, e.g. 100 = 1%) are collected on top.

Do not poll on-chain accounts for progress — the market API reports it. For a curve-phase Solana token, GET /api/v1/markets/solana/:mint returns live progress inside the migration block, read from the pool itself (5s cache):

"migration": {
"status": "not_started",
"reserveRaw": "250000000",
"thresholdRaw": "1000000000",
"progressBps": 2500
}

reserveRaw/thresholdRaw are in the quote asset’s raw base units; progressBps is the ratio in basis points, capped at 10000. Detecting external buys is a matter of watching reserveRaw grow beyond what your own trades put in.

Graduation is two stages. Stage 1 is on-chain and instant: the buy whose net-of-fee inflow pushes quoteReserve past migrationThreshold closes the curve (there is no partial fill or refund on Solana — the full buy lands, then the curve is done). Stage 2 is server-side aftercare: withdraw the liquidity, seed the Meteora DAMM v2 pool, mark the token complete, distribute staker rewards. Between the stages — typically seconds via the watcher, worst case a few keeper passes (minutes) — the market reports migration.status: "in_progress", and both buys and sells return MARKET_NOT_TRADABLE (409, “closed while graduating”). This is a normal, transient state: retry after status flips to "completed", do not treat it as a broken market.

After graduation the token row carries complete: true and poolAddress (the DAMM v2 pool); trades on this same endpoint pair route through Jupiter automatically. One caveat: Jupiter needs a short while to index a freshly created pool, so the first minutes after graduation can quote no route — that also clears on its own.

The curve.graduated webhook fires to the token creator’s registered endpoints the moment graduation is observed (see Webhooks below) — for an agent that launched the token, subscribing beats polling entirely.

Once a Hood curve graduates to its Uniswap pool, this rail refuses to trade it: there is no server-side Hood DEX (Uniswap) builder yet, so both a migrated curve and one mid-graduation (buys closed, pool not yet seeded) return MARKET_NOT_TRADABLE (409), naming Uniswap as where trading continues. This is a deliberate scope cut, not a bug: Solana has no equivalent gap (a graduated Solana token routes through Jupiter automatically, on this same endpoint pair).

A separate, one-shot endpoint, POST /api/v1/agent/swap (swap:write scope, apps/api/src/routes/agent.ts), converts between the platform’s own closed set of base assets — SOL, USDC, CNDL on Solana; ETH, USDG on Hood — never an arbitrary token. It is free for every tier: no platform fee is charged, and its amount is not subject to any spend cap (the spend gate described above governs this trade rail’s buys only, never a base-asset swap). It is still gated like every other agent surface, though: the AGENT_TRADING_ENABLED kill switch, the swap:write scope, and the caller’s own per-key rate limit all apply exactly as they do here. A route-level test pins this behavior: a successful base-pair swap’s response carries no fee-shaped field and executes exactly one broadcast, so a future change cannot silently reintroduce a fee.

The one-shot rail is main-wallet only. It quotes and executes server-side through the key owner’s own embedded wallet(s); it has no payer parameter and never touches a linked wallet (Candle never signs for a linked wallet, and the sign relay refuses any transaction the API did not build). A linked wallet converts base assets through the trade rail instead, as a base-pair trade: POST /trade/agent/build with a base-asset mint (USDC, CNDL, or wSOL So11...112) against a base quoteAsset — e.g. mint: <USDC>, quoteAsset: "sol", side: "buy" swaps SOL into USDC from the linked wallet through the normal build -> relay-sign -> submit flow. The class carries the swap rail’s contract with it: every tier (no Pro/Max requirement), no platform fee (fee.bps: 0), and no spend cap, while the kill switch, scope, and rate limit apply as usual. Solana only, like the arbitrary path; the same asset on both legs is rejected with VALIDATION_FAILED.

Linked cross-chain swaps: SOL into ETH/USDG

Section titled “Linked cross-chain swaps: SOL into ETH/USDG”

The one-shot rail above cannot bridge from a linked wallet, so cross-chain conversion has its own linked pair of endpoints in the trade rail’s build -> relay-sign -> submit shape:

  1. POST /api/v1/agent/swap/build {from: "SOL", to: "ETH"|"USDG", amountRaw, payer: {type: "linked", linkedWalletId}, toWalletId?, maxSlippageBps?} — quotes the bridge (Relay) with the linked wallet as payer, compiles the unsigned Solana deposit transaction, and stamps its bytes as kind: "swap" claims so the sign relay will sign them. Returns {swapId, transactionsBase64, expectedOutRaw, statusChecks, recipient, expiresAt}.
  2. Sign each transaction through the sign relay (signLinkedTransaction() in the SDK). Sign promptly: the deposit carries a recent blockhash and expires in about a minute.
  3. POST /api/v1/agent/swap/submit {swapId, signedTransactionsBase64} — verifies each signed transaction against the build by MESSAGE bytes (a swapped-in transaction is refused as UNRECOGNIZED_TRANSACTION before any broadcast), broadcasts server-side, and returns {hashes, statusChecks, recipient}. hashes proves the Solana deposit; the bridge fill is asynchronous — poll statusChecks to observe it complete.

The SDK wraps all three as swapFromLinked({from: "SOL", to, amountRaw, payer: {linkedWalletId, privyWalletId}, toWalletId?}).

Destinations are own-wallets-only. toWalletId must be a linked EVM wallet of the SAME account (attribution-only links qualify — receiving needs no signing); omitted, the output lands on the owner’s embedded Hood wallet. An arbitrary address is never accepted, so an over-scoped key can rearrange the account’s funds but not exfiltrate them.

v1 scope: from: "SOL" only. SOL/USDC/CNDL conversions are same-chain — use the free base-pair trade above; a USDC/CNDL origin converts to SOL that way first, then bridges. Hood origins remain the one-shot main-wallet rail. Builds expire after 10 minutes server-side (and practically after ~1 minute with the blockhash); an expired swapId answers 404 — rebuild.

Build expiry and built-replay term refresh

Section titled “Build expiry and built-replay term refresh”

A "built" row (linked payer only) that never gets confirmed lazily expires after 30 minutes, the same window and the same lazy, per-account sweep mechanism self-signed launches use (see Ledger and counter semantics in the headless-launch doc): every /build call sweeps the CALLING account’s own other abandoned "built" rows before doing anything else; there is no cron and no cross-account sweep. Once a row is swept (or simply old enough that a later /build call swept it), /confirm against it reports JOB_NOT_FOUND, naming that a rebuild is needed.

Calling /build again with the same clientTradeId and the same body while the row is still "built" and unexpired re-quotes it: the artifact is rebuilt fresh (a new Solana blockhash; a Hood approval re-checked against the payer’s current on-chain allowance) and the row’s economic terms (fee, expectedOutRaw, minOutRaw, plus builtAt) are refreshed to that new quote, inside agentTrades.begin’s own transaction (see its existing.status === "built" branch). Row, response, and artifact therefore always agree, which is load-bearing: /confirm verifies the broadcast against the ROW, so a row left pinned to a superseded quote would reject a legitimately fresh artifact. expiresAt in that replay response is the refreshed builtAt plus 30 minutes, so a rebuild restarts the window rather than inheriting the original one. An artifact signed before a rebuild is intentionally rejected afterwards.

A main-payer row stuck "built" (an indeterminate broadcast, or a genuinely concurrent request) is never silently re-executed on a same-id retry — that could double-spend if the original attempt actually landed. It returns the retryable IDEMPOTENCY_CONFLICT (409) instead, same as an in-flight headless launch.

trade.executed (see Webhooks in the headless-launch doc for registration, signing, and retry mechanics, all shared) fires once, after any trade that actually records: a main payer’s inline execution, or a linked payer’s verified /confirm. Never fired for a failed verification or an idempotent replay.

EventWhenPayload core
trade.executedAn agent trade recordedclientTradeId, chain, side, mint, quoteAsset, amountRaw, expectedOutRaw, signature, fee: { bps, feeRaw }
order.triggeredA limit order’s price condition held and its status flipped to triggered. Moves no money: the agent still completes the order through /build -> sign -> /confirm. Exists so a consumer can drop the poll on GET /orders?status=triggeredclientOrderId, chain, mint, side, amountRaw, targetPrice, price (the observed price that satisfied the condition), expiresAt

Task 4/5’s fee legs, referenced above, ship alongside this rail on both existing launch paths (see docs/headless-launch.md for the launch flows themselves):

  • Delegated headless (POST /api/v1/launch/headless, owner-signed via Privy): the fee, when the launch carries a nonzero dev buy, rides inside the same Solana launch transaction (like a trade’s own Solana fee leg), or is sent as a companion Hood transaction after the dev buy’s own receipt lands (best-effort, same non-fatal posture as a main-payer trade’s Hood fee).
  • Self-signed (POST /api/v1/launch/self/build + /confirm): the fee, when the requested dev buy is nonzero, is embedded directly in the unsigned Solana build transaction, or returned as its own feeTransfer artifact in the Hood build response — structurally identical to a linked payer’s trade artifacts. /confirm requires feeTxHash under the same FEE_LEG_MISSING rule described above, and a Hood fee hash is single-use across launches and trades alike (see the previous section).

Stated plainly: the fee binds to API-built transactions. Everything above — the spend gate, the fee-stripped-confirm refusal, the signature/hash reuse guard — constrains transactions this API assembled. An agent that hand-builds a transaction directly against the deployed curve or Jupiter contracts, bypassing /build entirely, pays only the on-chain curve fees every trader already pays, and nothing more. That trade still attributes correctly — the Hood curve-trade ingester (the keeper that catches trades made outside Candle’s own UI; see Attribution-only linking in the headless-launch doc) and Solana’s own activity-report path both resolve identity the same way regardless of how the transaction was built — it simply does not carry this platform fee. This is a scope boundary, not a bug: enforcing a fee on activity Candle never assembled would require policing every possible on-chain interaction with the curve, not just the ones this API produces.

GET /api/v1/markets/:chain/:mint/forensics (public, keyless, candle_token_forensics in the MCP) reports four things about a launch and scores them:

  • deployer: every other token this account launched, and how each ended (graduated, onCurve, recovery).
  • deployWindow: who bought in the first moments. Solana reads the launch slot plus two from the pool’s own history; Hood reads the curve’s Bought events, which the ingester already writes to the activity tape. The creator’s own wallets (embedded and imported) are marked disclosed: an atomic launch’s first-buy legs and the dev buy are bundled by design and on the ledger, so they are never the bundle signal. A wallet the account does not own buying in the same window is.
  • concentration: Solana, the largest token accounts against supply with the curve vault excluded; Hood, net curve purchases per wallet, labeled as such.
  • risk: tier (LOW / MODERATE / HIGH / CRITICAL), score (0-100), and one entry per factor with its value, points, and reason, so an agent can explain a refusal.

Every measurement carries coverage (complete, partial, unavailable) and a note. unavailable contributes no risk points and must never be read as clean. Funding-lineage clustering (who funded each buyer) is deliberately absent in v1 and reported as null with the reason. Rate-limited per IP; cached a minute per mint.

Every error response is the same structured envelope every other agent endpoint uses: { "success": false, "error": { "code", "message", "field"?, "retryable" } }. VALIDATION_FAILED, IDEMPOTENCY_CONFLICT, JOB_NOT_FOUND, EXCLUSIVE_NOT_ELIGIBLE, and SCOPE_MISSING mean exactly what they mean everywhere else in the API (see Errors in the headless-launch doc); this rail’s own codes, and a few reused codes worth a trade-specific note, are below. /submit mints no error code of its own: a malformed body or wrong leg count is VALIDATION_FAILED, an unresolvable clientTradeId is JOB_NOT_FOUND, a broadcast failure is SWAP_FAILED, and its post-broadcast confirmation reuses /confirm’s own on-chain checks (FEE_LEG_MISSING, SIGNER_MISMATCH) verbatim — so the table below covers /submit too.

CodeStatusMeaningRetryable
TEST_ENVIRONMENT_FORBIDDEN403A test-environment key called either endpoint; this rail has no non-production mode.no
MARKET_NOT_FOUND404mint resolves to no tradable market: a Hood/EVM address with no Candle token row (no arbitrary-mint path exists on Hood), or a Solana mint Jupiter cannot route (NO_ROUTES_FOUND) or does not recognize (TOKEN_NOT_TRADABLE) on the Pro/Max arbitrary path. See Tier gating above.no
TIER_REQUIRED403A Free or Believer key named a Solana mint with no Candle tokens row; Pro or Max is required to trade an arbitrary mint. See Tier gating above.no
MARKET_NOT_TRADABLE409The curve cannot execute this side right now: graduated to a Hood DEX (Uniswap, not yet buildable here), mid-graduation with its pool not yet seeded, or (Solana) not found on-chain. Message names where trading continues when known.no, per the response envelope — though a Solana “not found on-chain” case may genuinely resolve if retried later
QUOTE_PAIR_UNKNOWN400The market’s quote asset does not resolve to a known pair.no
SPEND_LIMIT_EXCEEDED400A buy’s amountRaw + feeRaw exceeds the payer’s own configured spend limit (main or linked scope).no, as the same amount; smaller amount or a raised limit
FEE_LEG_MISSING402/confirm: the broadcast (and, on Hood, the reported feeTxHash) does not verify a required fee transfer, or (Hood) feeTxHash was omitted despite the build carrying a fee. Also returned when a fee/trade signature was already claimed by a different trade or launch.no, only a fresh /build recovers
SIGNER_MISMATCH403/confirm: the broadcast was not actually signed (Solana) or sent (Hood) by the declared linked wallet.no
AGENT_WALLET_MISSING400Main payer: the account has no embedded wallet for the market’s chain.no
SWAP_FAILED500An unclassified failure executing or recording a leg (broadcast/RPC error, an on-chain revert, a post-broadcast recording failure). Message states whether the underlying transaction may already be on-chain.sometimes; message states which

Main-payer signing failures. SPEND_POLICY_DENIED (403, the owner wallet’s own Privy spend policy rejected the transaction), DELEGATION_REVOKED (403, delegation existed but Privy now rejects the signing/sending call), and INSUFFICIENT_OWNER_BALANCE (402, the owner wallet cannot cover the trade plus fee) can all surface from a main payer’s inline execution. On this rail all three report retryable: false in the envelope — unlike their headless-launch counterparts, which mark DELEGATION_REVOKED/INSUFFICIENT_OWNER_BALANCE retryable once the underlying cause is fixed (re-delegating, funding the wallet). The underlying fix is the same either way; only the envelope’s own hint differs by route, so do not rely on retryable alone here for these three — check code and act accordingly regardless of what the flag says.

packages/sdk/src/client.ts’s CandleClient.buildTrade() / .confirmTrade() / .submit() wrap the three calls above exactly; the SDK never signs anything, only assembles and parses the HTTP requests/responses (same posture as buildSelfLaunch()/confirmSelfLaunch()). All three require an agent key with swap:write (see Auth above), and throw CandleApiError on every non-2xx response, same as every other client method.

BuildTradeResult (the buildTrade() return type) is a discriminated union: status: "built" narrows artifacts by chain (SolanaTradeArtifacts with transactionBase64, or HoodTradeArtifacts with trade/approval?/feeTransfer?); status: "executed" (returned directly for a main payer, or from confirmTrade() or submit()) carries signature/ feeSignature? instead, chain-agnostic. See the JSDoc on buildTrade()/confirmTrade()/ submit()/trade() in client.ts for the exact signing responsibilities per chain and payer mode — including trade()’s own per-chain default (Solana submits server-side via submit(); Hood stays on the client-broadcast sequence) — and client.test.ts’s BuildTradeResult chain discriminant (type-level) block for how the discriminant’s compile-time narrowing is pinned.

Roadmap item D. A Max-tier agent can place a limit order: a target price, side, and amount on a Candle-launched Solana or Hood token. Candle watches the price (via the same tokenMarkets data the rest of the platform already refreshes every 30 seconds) and flips the order to triggered when the condition is met. There is no push notification: the agent’s own online loop polls GET /trade/agent/orders?status=triggered to discover a trigger, then completes the trade through the existing, unmodified POST /trade/agent/build -> sign -> POST /trade/agent/submit sequence documented above, and finally reports completion through POST /trade/agent/orders/:clientOrderId/fill.

An order only fills while the agent is online to poll for it. There is no new signing model: the agent still signs at completion time through the sign relay, exactly like every other linked-payer trade on this rail. Main-payer orders are not supported; only a linked wallet may place one.

Auth and tier. Same agent key (x-api-key, swap:write scope) as the rest of this rail, but POST /orders additionally requires Max tier specifically (TIER_REQUIRED for a Free, Believer, or Pro key), not the Pro-or-Max gate the arbitrary-mint path above uses. GET/DELETE/fill have no additional tier gate of their own (a non-Max key simply never has any orders to act on, since placement itself is Max-only).

Scope. Candle-launched tokens only, same tokens.getByMint check the rest of this rail uses. A non-Candle mint gets MARKET_NOT_FOUND; the arbitrary-Solana-mint trading Pro/Max get on /build does not extend to limit orders in this version.

Place an order.

{
"clientOrderId": "order-abc123",
"linkedWalletId": "linked-wallet-id",
"mint": "So1...",
"side": "buy",
"amountRaw": "1000000",
"maxSlippageBps": 100,
"targetPrice": "1.50",
"expiresAt": 1755000000000
}

side implies direction: buy triggers at-or-below targetPrice; sell triggers at-or-above. expiresAt is a unix-ms timestamp, at most 30 days out. clientOrderId is the idempotency key, unique per account: the same id with the same body replays the original order; the same id with a different body gets IDEMPOTENCY_CONFLICT. An account may have at most 25 open orders at once (ORDER_LIMIT_REACHED past that).

The agent’s own poll target. Lists the caller’s own orders, optionally filtered by open/triggered/filled/expired/cancelled. An online agent calls this periodically (a sensible interval is whatever cadence the agent already runs its own loop at) with ?status=triggered to discover orders ready to complete.

DELETE /api/v1/trade/agent/orders/:clientOrderId

Section titled “DELETE /api/v1/trade/agent/orders/:clientOrderId”

Cancel. Allowed from any status except filled (ORDER_ALREADY_FILLED if already filled).

POST /api/v1/trade/agent/orders/:clientOrderId/fill

Section titled “POST /api/v1/trade/agent/orders/:clientOrderId/fill”

Called by the agent once it has completed a triggered order’s trade through /build -> sign -> /submit. Body: { "clientTradeId": "..." }, the SAME clientTradeId used on that /build call. This is verified server-side against the durable trade ledger before the order is marked filled; the agent’s call alone is never trusted. ORDER_NOT_TRIGGERED if the order is not currently triggered (still open, or already acted on). TRADE_NOT_CONFIRMED if clientTradeId does not resolve to a confirmed trade on this same account.

CodeStatusMeaningRetryable
TIER_REQUIRED403POST /orders from a non-Max key.no
MARKET_NOT_FOUND404mint is not a Candle-launched token.no
ORDER_LIMIT_REACHED400The account already has 25 open orders.no, until one is filled/cancelled/expires
ORDER_NOT_FOUND404clientOrderId does not resolve to an order on this account.no
ORDER_ALREADY_FILLED409DELETE on an already-filled order.no
ORDER_NOT_TRIGGERED409/fill called on an order that is not currently triggered.no
TRADE_NOT_CONFIRMED409/fill’s clientTradeId does not resolve to a confirmed trade on this account.yes, once the referenced trade actually confirms
TRADE_ALREADY_CLAIMED409/fill’s clientTradeId has already been used to fill a different order.no, a fresh trade and clientTradeId are required

This feature added a new Convex table (limitOrders) and new crons/mutations/queries. Per this project’s standing constraint, a Vercel preview deployment runs against the staging Convex backend and does not apply a branch’s own schema changes, so the preview URL will not reflect this feature until it merges. Verify against local dev Convex (bunx convex dev) instead.