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
mintwith no Candletokensrow routes throughplanArbitrarySolanaJupiterTrade: 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
mintwith no Candletokensrow returnsTIER_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 Candletokensrow always returnsMARKET_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.
The two payer modes
Section titled “The two payer modes”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/buildreturns: the response isstatus: "executed"outright, and there is nothing left to sign, broadcast, or confirm./confirmis never called for a main-payer trade; calling it anyway is rejectedVALIDATION_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./buildreturnsstatus: "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’sCandleClient.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 toPOST /api/v1/trade/agent/submit(see Server-side submit below), which broadcasts and confirms it through Candle’s own RPC — so a Solana-linkedtrade()call needs nosolanaRpcUrlconfigured at all. For a Hood-linked payer, the client must configureevmRpcUrl(an EVM JSON-RPC endpoint, separate fromsolanaRpcUrl) —trade()throws a clear error naming it when unset, before any signing. With it set,trade()assembles each ofartifacts.approval(if present),artifacts.trade, andartifacts.feeTransfer(if present) as its own EIP-1559 (type 2) transaction — fetching nonce, chain id, and fee data fromevmRpcUrl— 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: thetradeleg’s gas estimate reverts if it runs before theapprovalleg is mined, since the on-chain allowance is not yet set. It then callsconfirmTrade({ 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 returnMARKET_NOT_TRADABLEfor 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.
1. POST /api/v1/trade/agent/build
Section titled “1. POST /api/v1/trade/agent/build”{ "clientTradeId": "my-bot-trade-1", "mint": "So1anaTokenMint...", "side": "buy", "amountRaw": "1000000000", "payer": { "type": "main" }, "maxSlippageBps": 100}| Field | Required | Notes |
|---|---|---|
clientTradeId | yes | Idempotency 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). |
mint | yes | The 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. |
side | yes | "buy" or "sell". |
amountRaw | yes | A positive integer string, in raw units. Buy: quote-asset raw units to spend. Sell: base-token raw units to sell. |
payer | yes | { "type": "main" } or { "type": "linked", "linkedWalletId": "..." }. See The two payer modes above. |
maxSlippageBps | no | Integer, 0-10000. Defaults to 100 (1%). |
quoteAsset | no | "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.transactionBase64via 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’sCandleClient.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 withbroadcastSignedTransaction()and report the result to/confirm. - Hood. Send
artifacts.approval(if present), thenartifacts.trade, thenartifacts.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: withevmRpcUrlconfigured,CandleClient.trade({ from: { linkedWalletId, privyWalletId } })assembles each leg as an EIP-1559 (type 2) transaction (nonce, gas, and fee data fetched fromevmRpcUrlviapackages/sdk/src/evm-tx.ts’s helpers), signs it withsignLinkedTransaction({ chain: "evm", evmTxParams }), broadcasts it withbroadcastSignedTransaction(), 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 withsignLinkedTransaction({ chain: "evm", evmTxParams }), broadcast withbroadcastSignedTransaction()(or your own EVM client), then callconfirmTrade()with the resultingtradeTxHash/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.
3. POST /api/v1/trade/agent/confirm
Section titled “3. POST /api/v1/trade/agent/confirm”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.
Referral attribution
Section titled “Referral attribution”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.
Fee model
Section titled “Fee model”| Tier | Transaction fee |
|---|---|
| Free | 100 bps (1%) |
| Believer | 50 bps (0.5%) |
| Pro | 25 bps (0.25%) |
| Max | 0 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 —
amountRawon Solana, and on Hood the consumed quote (quoteConsumed, not the submittedamountRaw) 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 equalsamountRawexactly, 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
feeRawonce the swap credits them (whichexpectedOutRaw, floored byminOutRaw, guarantees). There is no separate fee signature to track for a main payer; for a linked payer,/confirmclaims the trade’s own broadcast signature as its (unconditional) fee-reuse guard — see the SDK reference onfeeSignaturefor 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, andfeeSignatureis 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 asfeeTxHashat/confirm, where it is a hard requirement, not best-effort — see the next section.
The fee-stripped-confirm refusal
Section titled “The fee-stripped-confirm refusal”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:
feeTxHashmust be present in the confirm body at all (its absence alone isFEE_LEG_MISSING), and must verify as a real transfer of at leastfeeRawto 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.
Per-key spend limits
Section titled “Per-key spend limits”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.
Key management
Section titled “Key management”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:
| Field | Notes |
|---|---|
label | 1-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”, …). |
expiresInDays | Integer, 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. |
expiresAt | Absolute 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.
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.”
# Renamecurl -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 limitcurl -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 itcurl -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.
KEY_EXPIRED (401)
Section titled “KEY_EXPIRED (401)”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.
KEY_LIMIT_REACHED (403)
Section titled “KEY_LIMIT_REACHED (403)”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.
Curve mechanics and graduation (Solana)
Section titled “Curve mechanics and graduation (Solana)”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.
The Hood-DEX boundary
Section titled “The Hood-DEX boundary”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).
The base-asset swap rail is free
Section titled “The base-asset swap rail is free”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:
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 askind: "swap"claims so the sign relay will sign them. Returns{swapId, transactionsBase64, expectedOutRaw, statusChecks, recipient, expiresAt}.- 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. POST /api/v1/agent/swap/submit{swapId, signedTransactionsBase64}— verifies each signed transaction against the build by MESSAGE bytes (a swapped-in transaction is refused asUNRECOGNIZED_TRANSACTIONbefore any broadcast), broadcasts server-side, and returns{hashes, statusChecks, recipient}.hashesproves the Solana deposit; the bridge fill is asynchronous — pollstatusChecksto 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.
Webhooks
Section titled “Webhooks”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.
| Event | When | Payload core |
|---|---|---|
trade.executed | An agent trade recorded | clientTradeId, chain, side, mint, quoteAsset, amountRaw, expectedOutRaw, signature, fee: { bps, feeRaw } |
order.triggered | A 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=triggered | clientOrderId, chain, mint, side, amountRaw, targetPrice, price (the observed price that satisfied the condition), expiresAt |
Fee legs on launches
Section titled “Fee legs on launches”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 ownfeeTransferartifact in the Hood build response — structurally identical to a linked payer’s trade artifacts./confirmrequiresfeeTxHashunder the sameFEE_LEG_MISSINGrule described above, and a Hood fee hash is single-use across launches and trades alike (see the previous section).
The enforcement boundary
Section titled “The enforcement boundary”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.
Token forensics: the pre-buy gate
Section titled “Token forensics: the pre-buy gate”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
Boughtevents, which the ingester already writes to the activity tape. The creator’s own wallets (embedded and imported) are markeddisclosed: 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.
Errors
Section titled “Errors”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.
| Code | Status | Meaning | Retryable |
|---|---|---|---|
TEST_ENVIRONMENT_FORBIDDEN | 403 | A test-environment key called either endpoint; this rail has no non-production mode. | no |
MARKET_NOT_FOUND | 404 | mint 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_REQUIRED | 403 | A 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_TRADABLE | 409 | The 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_UNKNOWN | 400 | The market’s quote asset does not resolve to a known pair. | no |
SPEND_LIMIT_EXCEEDED | 400 | A 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_MISSING | 402 | /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_MISMATCH | 403 | /confirm: the broadcast was not actually signed (Solana) or sent (Hood) by the declared linked wallet. | no |
AGENT_WALLET_MISSING | 400 | Main payer: the account has no embedded wallet for the market’s chain. | no |
SWAP_FAILED | 500 | An 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.
Limit orders
Section titled “Limit orders”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.
POST /api/v1/trade/agent/orders
Section titled “POST /api/v1/trade/agent/orders”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).
GET /api/v1/trade/agent/orders?status=
Section titled “GET /api/v1/trade/agent/orders?status=”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.
Error codes (limit orders)
Section titled “Error codes (limit orders)”| Code | Status | Meaning | Retryable |
|---|---|---|---|
TIER_REQUIRED | 403 | POST /orders from a non-Max key. | no |
MARKET_NOT_FOUND | 404 | mint is not a Candle-launched token. | no |
ORDER_LIMIT_REACHED | 400 | The account already has 25 open orders. | no, until one is filled/cancelled/expires |
ORDER_NOT_FOUND | 404 | clientOrderId does not resolve to an order on this account. | no |
ORDER_ALREADY_FILLED | 409 | DELETE on an already-filled order. | no |
ORDER_NOT_TRIGGERED | 409 | /fill called on an order that is not currently triggered. | no |
TRADE_NOT_CONFIRMED | 409 | /fill’s clientTradeId does not resolve to a confirmed trade on this account. | yes, once the referenced trade actually confirms |
TRADE_ALREADY_CLAIMED | 409 | /fill’s clientTradeId has already been used to fill a different order. | no, a fresh trade and clientTradeId are required |
Convex/preview note
Section titled “Convex/preview note”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.