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 on either chain, a graduated Solana token routed through Jupiter, or a graduated Hood token whose Uniswap pool is seeded), every tier can trade it. Pro and Max can additionally trade any mint the rail can route: an arbitrary Solana mint through Jupiter, or an arbitrary Hood mint through the DEX race — 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.

The same cndl_live_ agent key also authenticates to hacc (api.hacc.fun) as Authorization: Bearer. Pro and Max use that host for follows, tenanted rules, and (Max) invert/lab. Candle never proxies those routes. Partner pull for hacc is GET /api/v1/hacc/keys/:prefix and GET /api/v1/hacc/account/:address, scoped hacc:sync (partner-only, same rule as entitlement:read).

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, a graduated Solana token routed through Jupiter, or a graduated Hood token whose Uniswap pool is seeded (mint resolves to a Candle tokens row). On top of that:

  • Pro and Max can also trade any mint the rail can route, Candle-launched or not. A Solana mint with no Candle tokens row goes through Jupiter against a chosen base quote asset (quoteAsset: "sol" | "usdc" | "cndl", default "sol"). A Hood 0x mint with no Candle tokens row goes through the DEX race against "eth" or "usdg" (default ETH). 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 or Hood mint with no Candle tokens row returns TIER_REQUIRED (403), naming that Pro or Max is required.
  • A Hood mint the market provider has never seen, with no poolKey hint, is MARKET_NOT_FOUND (reason: hood_unindexed), not a tier refusal. Mid-graduation with the pool not yet seeded is still MARKET_NOT_TRADABLE (409, retryable). See Errors below.

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, on the curve and on the DEX. 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.permit2Approval (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. A USDG-settled DEX buy now takes that approval path, which ETH-terminal buys do not (native value rides in msg.value). It then calls confirmTrade({ clientTradeId, tradeTxHash, feeTxHash? }). A graduated Hood market whose pool is seeded builds on the DEX venue (artifacts.venue: "dex"); see Hood venues below for which artifacts.trade.to a fill can hit. Mid-graduation with an unseeded pool is still MARKET_NOT_TRADABLE (409, retryable). To drive the legs yourself instead of using the one-shot, see Flow below for the manual sequence.

Pricing before you trade: POST /api/v1/trade/agent/quote

Section titled “Pricing before you trade: POST /api/v1/trade/agent/quote”

A live price for a Hood token, from the same quoters /build asks, without building anything.

POST /api/v1/trade/agent/quote
{ "mint": "0x...", "side": "buy", "amountRaw": "1000000", "quoteAsset": "eth" }

quoteAsset is optional. On Hood it is "eth" (default) or "usdg": the asset the quote is priced in, and the asset /build will settle in if you forward the field. A Solana id here is a 400. Take the returned quoteAsset straight to /build; echoing it is safe.

{
"success": true,
"chain": "hood",
"venue": "dex",
"amountOut": "7000000",
"quoteAsset": "eth",
"quoteDecimals": 18,
"route": { "source": "uniswap", "kind": "weth", "hops": [ ... ], "priceImpactBps": 42 }
}

Why it exists: the public market quote (/api/v1/markets/hood/:mint/quote) reads a Candle curve, so a token Candle never launched had no live price on this API until a third-party indexer noticed it. A caller pricing fresh launches was refusing entries that were perfectly routable, because it had no price rather than no route.

It writes nothing. No trade row (a build row expires after 30 minutes, so a probe that created one would fill your ledger with expirations), no fee leg, no calldata, and no aggregator build call. amountOut is before slippage; the floor is set at /build, from maxSlippageBps.

/quote does not pin the /build route. Both calls race independently (and the aggregator, when configured). Hops can change, and the hop fee (the pool’s static tier) can change. A 2026-09-08 main-payer fill quoted bridge fee 100 and executed 500; both tiers are in HOOD_USDG_WETH_BRIDGE_FEES (100, 500, 3000) in packages/shared/src/hood-dex.ts. Size against minOutRaw from the build, not against a hop fee from the quote. priceImpactBps remains a depth statistic, not a cost.

amounts.quoteAsset on the executed (or built) response is what the wallet paid or received. route.kind is the path the race won. They answer different questions. A fill can read amounts.quoteAsset: "eth" alongside route.kind: "usdg": the wallet paid native ETH through a WETH → USDG → token path. Kind "usdg" is not “the wallet paid USDG”. Cost-basis and fee denomination key off amounts.quoteAsset. A receipt verifier keys off route (hops, kind, router). See Hood venues below.

Refusals are the same errors /build raises, error.routing included, so one classifier serves both. Hood only: Solana routes through Jupiter, which callers quote directly.

Trading a pool before any indexer knows it

Section titled “Trading a pool before any indexer knows it”

A launch’s first minutes were the window this rail could not serve: the market provider has not indexed the pool, so there are no candidates and the answer is a refusal, which is exactly when the trade was worth making.

If you are watching the chain you see the pool before we do. Pass its v4 PoolKey, read from the pool’s Initialize event (or its first Swap), as poolKey on /quote and on /build:

{
"mint": "0x...",
"side": "buy",
"amountRaw": "1000000000000000",
"poolKey": {
"currency0": "0x...", "currency1": "0x...",
"fee": 0, "tickSpacing": 60,
"hooks": "0x2a100c4b29c2e2c76dbbc794334f54d794d8a0cc"
}
}

Nothing in it is taken on trust. Before the key is used we check the shape v4 itself requires (currency ordering, fee, tickSpacing), that one side is EXACTLY the mint you are trading and the other is a counter asset this rail settles, that the hook passes this environment’s policy, and that the pool is initialized on chain and holds liquidity, read from the v4 StateView rather than from any indexer. A key with one field changed names a pool that does not exist, and reads that way.

A hint is extra reach, never a replacement: the indexed pools are still raced beside it, and the best execution wins. If your key fails verification and the token is otherwise tradable, the hint is dropped and the trade proceeds; if it was the only route, its refusal is the answer, so you learn which check it failed rather than seeing a bare “no route”.

Rate limit: 600 quotes per minute per key, ten a second sustained. Sized for a caller pricing every open position on every cycle; over it you get RATE_LIMITED (429, retryable). Per key, so one account’s loop cannot throttle another’s.

route is on the quote, for both quoters. The aggregator path and the in-house race both stamp source, hops and priceImpactBps here, not only on a build, because an impact floor has to run before a build exists.

What a trade costs, and which field does NOT tell you. This one has bitten a caller already, so it is worth being blunt.

priceImpactBps is a depth statistic, not a cost. It compares two sizes on the same route, so any proportional fee is paid by both quotes and cancels out of the number entirely. A v4 hook’s charge is invisible in it. So is the hop’s fee field, which reports the pool’s STATIC tier: a launchpad pool whose hook takes its cut in afterSwap reports fee: 0 truthfully.

Measured on a real pool: fee: 0, priceImpactBps: 4, and a round trip through it cost 604 bps. A caller adding declared fee to impact underestimated the cost of that pool by about fiftyfold.

For the all-in number, ask for it:

{ "mint": "0x...", "side": "buy", "amountRaw": "1000000000000000", "includeRoundTrip": true }

The response then carries roundTripBps: what buying and immediately selling the proceeds costs, including every fee on both sides and both impacts. It quotes the way back through the same planner, so it reflects the route you would really get on the way out, not an assumption that the return leg is symmetric. It is opt-in because it is a second quote’s work.

What priceImpactBps measures. How much worse this SIZE executes than a small one on the SAME route:

10000 * (1 - (amountOut / amountIn) / (refOut / refIn))

where the reference is 1% of the trade, quoted through the same route by the same quoter. It needs no USD oracle, so no vendor’s view of a token’s value can move it, and it is comparable between the two quoters. It is best-effort: when the reference quote fails the field is absent rather than guessed. A larger figure on a sell than on a buy of the same token is normal, since the two sides are different sizes against different pool depths.

{
"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 (Jupiter) or an arbitrary Hood mint (DEX race) with no Candle token row — see Tier gating above. A non-Candle mint on a Free/Believer key is TIER_REQUIRED. A Hood address the market provider has never seen, with no poolKey hint, is MARKET_NOT_FOUND (reason: hood_unindexed).
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%).
quoteAssetnoClosed set: "sol" | "usdc" | "cndl" | "eth" | "usdg". Echoing /quote’s quoteAsset is safe. On Solana, used only for an arbitrary (non-Candle) mint; default "sol"; ignored for a Candle-launched token whose pair is fixed; a Hood id on a Solana mint is 400 VALIDATION_FAILED. On Hood, "eth" (default) or "usdg": this is the settlement asset, what actually leaves the wallet. A USDG-settled buy spends an ERC-20, so the plan carries an extra approval leg that an ETH buy does not (one extra transaction for a main payer, one extra artifact for a linked payer). This is not the route: the cheapest path to that asset is raced independently, which is why a fill can read amounts.quoteAsset: "eth" alongside route.kind: "usdg". The executed amounts.quoteAsset is what was actually paid. Omitted on Hood is the same ETH-terminal trade as "eth" for settlement (the idempotency hash still treats omitted as "sol"; that is existing hash stability, not a different fill). Unknown values are 400 VALIDATION_FAILED and create no job.

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 when the input is an ERC-20 and the payer’s existing allowance is insufficient for amountRaw: a USDG-quoted curve buy, a USDG-settled DEX buy, or a token sell. An ETH-terminal buy never needs one (the input rides in msg.value). permit2Approval is a second grant, used on a v4 or bridged sell where the Universal Router pulls through Permit2. feeTransfer is present only when a fee actually applies (see Fee model below). Linked Hood must send approvalpermit2ApprovaltradefeeTransfer in that exact order, awaiting each receipt: the trade leg’s gas estimate reverts before the allowance is mined. USDG-settled DEX buys now enter that path, which ETH-terminal buys skip.

A main-payer Hood fill never returns artifacts (the server signs and broadcasts). The executed response carries the same route object a build does, plus amounts.quoteAsset.

artifacts.venue is "curve" on a live bonding curve and "dex" once the trade is a Uniswap (or aggregator) swap. Receipt verifiers should allowlist artifacts.trade.to (linked) or the fill’s to (main payer), not a Candle Swap event or a v4 PoolManager alone. /confirm and /reconcile allowlist exactly these routers plus the mint’s own curve.

VenueWhenartifacts.venueTypical toConstant
Candle curveLive bonding curve"curve"the mint’s curveAddresstoken row
Uniswap v3DEX race kind weth, usdg, usdg-direct, usdg-hop"dex"0xCaf681a66D020601342297493863E78C959E5cb2 (SwapRouter02, multicall)HOOD_UNIV3_ROUTER in packages/shared/src/hood-dex.ts
Uniswap v4 / bridged-v4DEX race kind v4, bridged-v4"dex"Universal Router default 0x8876789976decbfcbbbe364623c63652db8c0904HOOD_V4_UNIVERSAL_ROUTER (env-overridable)
Uniswap v2DEX race kind v2"dex"Router02 default 0x89e5db8b5aa49aa85ac63f691524311aeb649ebaHOOD_UNIV2_ROUTER (env-overridable)
Kyber aggregatorHOOD_DEX_AGGREGATOR=kyber and Kyber quoted"dex", route.source: "kyber"0x6131B5fae19EA4f9D964eAc0408E4408b66337b5HOOD_KYBER_ROUTER

route.kind values from the race: weth, usdg, usdg-direct, usdg-hop, v4, bridged-v4, v2. Kind usdg means the path crosses USDG (often WETH → USDG → token). It does not mean the wallet paid USDG. Settlement is quoteAsset / amounts.quoteAsset.

GET /api/v1/trade/agent/jobs/{clientTradeId} is the read path for a build that may have begun. Use it after a timeout or a restart rather than re-sending the write.

A 404 JOB_NOT_FOUND means no row for this account and id: the write never reached agentTrades.begin. A /build rejected at validation (400 VALIDATION_FAILED, including a bad quoteAsset) creates no row. Treat the 400 as definitive; do not poll. The same clientTradeId is reusable with a corrected body.

A /build that planned and began, then failed later, has a row (failed/expired semantics as already documented for /confirm).

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.permit2Approval (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).

Recording a Hood trade from its receipt: POST /api/v1/trade/agent/reconcile

Section titled “Recording a Hood trade from its receipt: POST /api/v1/trade/agent/reconcile”

A build row lives 30 minutes. After that /confirm answers JOB_NOT_FOUND and the trade can no longer be confirmed under its clientTradeId, even when the tokens are in the wallet and the caller has already booked the fill from the chain. /reconcile records such a trade from its receipt alone, with no build row, and needs only the hash, the mint and the side:

{ "tradeTxHash": "0x...", "mint": "0x...", "side": "buy", "clientTradeId": "my-bot-trade-3" }

Everything else is read from the chain and bound the way /confirm binds a live trade: the transaction succeeded; its sender is a wallet on this account (the embedded wallet or any linked wallet, SIGNER_MISMATCH otherwise); its to is an allowlisted Hood DEX router or the mint’s own Candle curve; and the sender’s own Transfer of the mint moved in the claimed direction, which also gives the recorded token amount. The native-ETH quote leg is the transaction’s value on a buy and the wallet’s balance change plus the gas it paid on a sell; the response’s quoteDerived says which ("value", "balance_delta", or "unavailable", in which case the token leg is still recorded with a zero quote). Hood only.

{
"status": "recorded",
"alreadyRecorded": false,
"chain": "hood",
"signature": "0x...",
"mint": "0x...",
"side": "buy",
"payerWallet": "0x...",
"venue": "dex",
"amountRaw": "250819583578439067446794",
"quoteAsset": "eth",
"quoteAmountRaw": "100000000000000000",
"quoteDerived": "value",
"feeRaw": "0",
"feeBps": 0,
"feeCollected": false,
"clientTradeId": "my-bot-trade-3"
}

Idempotent on the hash: a replay answers alreadyRecorded: true and writes nothing. No fee is collected: the agent-tier fee is a companion transaction /build composes, and none exists for a trade recorded this way. clientTradeId is echoed for your own bookkeeping only; an expired build row stays as it was.

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 four for Hood, in the fixed order approval and permit2Approval (each only when the build produced it), 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.

A Hood v4 sell carries two approval legs, not one. The Universal Router pulls the input token through Permit2, so approval grants Permit2 (not the router) and permit2Approval grants the router inside Permit2, for the exact amount with a short expiry. Sign and broadcast them in the order the build lists them: approval, permit2Approval, trade, feeTransfer. Either approval leg is absent when the existing grant already covers the amount and has not expired.

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.

A different control from KEY_LIMIT_REACHED above, and the difference is the thing to get right: that one caps how much a key may spend in a window and clears itself when the window rolls. This one caps how much a profile may lose, and it does not clear on any clock.

An owner sets any of three bounds on a profile, through PUT /api/v1/agent/keys/{prefix}/loss-limits:

boundwhat it measures
maxDailyLossUsdMicrosrealized loss inside the current UTC day
maxDrawdownUsdMicrosfall from the profile’s high-water equity mark, which never resets
maxConsecutiveLosseslosing closes in a row

Alongside them, every profile carries a circuit breaker that nobody opts into and nobody can turn off, because it catches malfunction rather than losses: too many trades inside a minute, the same token bought repeatedly without selling, and three failed sells of one token in a row.

Breaching any of them stops the profile. Every later build answers:

{
"success": false,
"error": {
"code": "RISK_LIMIT_REACHED",
"message": "This profile is stopped: it breached its daily loss limit. An account owner must clear it before trading resumes.",
"retryable": false,
"uiHint": "This profile stopped itself: it reached a loss limit or tripped its circuit breaker. Waiting will not clear it. An account owner reviews the profile and resumes it from the agent console."
}
}

retryable is always false, and an agent should believe it. There is no window to wait out and no smaller amount that gets through. The stop is cleared by a person, through the agent console or POST /api/v1/agent/keys/{prefix}/risk/resume, and that route refuses an API key on purpose: an agent that tripped its own limit must not be able to talk itself back into trading. The right response to this code is to stop trading on this profile and say why.

GET /api/v1/agent/keys/{prefix}/risk answers with the bounds, the rollup they are judged against, and which one broke. A key may read its own, so a stopped agent can report the reason without a human having to go and look.

A key may also tighten its own bounds through the PUT above, and may never loosen or clear one. Same rule txLimit follows, for the same reason.

Say this plainly: these are guardrails, not a hard accounting boundary. The rollup behind them is recomputed after each trade settles, so a bound stops the NEXT trade. It cannot unwind a position already open, and one large trade can carry a profile well past its limit before anything sees it. Two more consequences worth stating:

  • Zero is a real setting, not “unset”. maxDailyLossUsdMicros: 0 means stop on the first losing trade. Send lossLimits: null to clear the bounds instead.
  • A profile with bounds set is refused when its risk state cannot be read. This is the opposite of KEY_LIMIT_REACHED, which fails open on infrastructure trouble, and it is deliberate: a volume cap bounds what an abuser spends, while a loss limit bounds the owner’s own money while they are not watching. A profile with no bounds set is never affected by this.

GET /api/v1/markets/feed?bucket=new|graduated|onfire|bluechip is the public read behind candle_get_feed. Unfiltered it returns every token in the bucket with all of its fields, which measured about 135KB and will not fit in an agent’s tool result.

Four query parameters narrow it server-side. No new data, no new pipeline; the caller says what it wants.

parameterformwhat it does
whereJSON objectField constraints. Comparators: eq, ne, lt, lte, gt, gte, present.
sortfield or field:asc / field:descA bare field means descending.
fieldscomma listWhich fields to return. chain, address and symbol always ride along.
limit1 to 200Applied after filtering and sorting.
GET /api/v1/markets/feed?bucket=graduated
&where={"marketCap":{"lt":150000},"liquidityUsd":{"gte":25000},"mintAuthorityDisabled":{"eq":true}}
&sort=change1h:desc
&fields=symbol,marketCap,liquidityUsd,holderCount,change1h
&limit=20

That is roughly 2KB.

The response adds two counts beside tokens: matched, how many rows the filter found before the limit, and scanned, how many were in the bucket before the filter. Without them a caller that asked for 20 and received 20 cannot tell a complete answer from a first page, or a filter that matched nothing from a bucket that was empty.

Say this plainly: a missing field is not a false one

Section titled “Say this plainly: a missing field is not a false one”

mintAuthorityDisabled and freezeAuthorityDisabled were each absent on 17% of the rows in one measured response, and liquidityUsd and holderCount on 16%. An absent field means nobody checked. It does not mean the authority is disabled, and it does not mean the liquidity is zero.

So an absent field satisfies no comparison at all. {"mintAuthorityDisabled":{"eq":true}} returns only tokens that actually say true, never tokens where the flag is missing. There is exactly one operator an absent field can satisfy, and asking for it is how you measure the coverage gap rather than guessing at it:

&where={"mintAuthorityDisabled":{"present":false}}

The same rule governs sorting: rows missing the sort field sink to the bottom in both directions. A token with no marketCap is not the smallest token on the platform, so an ascending sort must not hand it back as the cheapest.

Unknown fields and comparators are refused, not ignored. A filter on a misspelled field that silently matched everything would return the whole feed to a caller who believed they had narrowed it, which is the exact failure these parameters exist to prevent.

Watching cheaply, with a cursor you already have

Section titled “Watching cheaply, with a cursor you already have”

Polling the feed to see what moved means re-reading the whole bucket, which is the cost this section exists to avoid. updatedAt turns that into a delta:

GET /api/v1/markets/feed?bucket=new&where={"updatedAt":{"gt":1757520000000}}&limit=200

Keep the largest updatedAt you have seen and pass it back next time. Milliseconds, not seconds.

A row’s stamp only moves when its numbers do. The refresh re-sends every row on every tick, and the write is skipped when nothing but the clock changed. So an unchanged token does not come back in your next delta, which is exactly what makes this worth doing rather than a way of re-reading the same page with extra steps.

Two caveats. The window is what the bucket currently holds, so a token that left the feed entirely does not appear as a deletion; compare against the set you are holding if disappearance matters. And matched beside the results tells you how many rows changed before the limit, which is how you find out you are polling too slowly.

liquidityUsd is a level, and a level says nothing about direction. A pool holding $40,000 might be filling or might be halfway out the door, and those are opposite trades.

Two fields carry the memory that tells them apart:

fieldwhat it is
liquidityHighWaterUsdthe highest liquidity recorded for this token since it entered the feed
liquidityDrawdownBpshow far below that peak the latest reading sits, in basis points

The mark ratchets up only. A pool that drained and partly refilled is still measured against what it once held, because that is the number that says how much has left. 5000 is half the peak gone.

This matters because liquidity leads the price. By the time a candle shows the damage the exit is already worse, so a drain is the earliest actionable warning in this data. Screen on it like any other field:

GET /api/v1/markets/feed?bucket=graduated
&where={"liquidityDrawdownBps":{"gte":3000}}
&sort=liquidityDrawdownBps:desc
&fields=symbol,liquidityUsd,liquidityHighWaterUsd,liquidityDrawdownBps

Or invert it, and hold only what is not bleeding: {"liquidityDrawdownBps":{"lt":1000}}.

Absent is not drained, and this is the rule that matters. liquidityUsd is missing on roughly one row in six. A token whose figure disappears has not drained to zero; nobody knows what it is. So a missing reading produces no drawdown at all rather than a 100% one, and it leaves the mark where it was. The field is simply absent, which the filter already treats as unknown.

That leaves you the choice, which is the point. {"liquidityDrawdownBps":{"present":false}} is the unpriced sixth of the market, and whether you trade it is yours to decide. What the API will not do is report those tokens as rugs.

Two limits worth stating plainly. The mark resets if a row leaves the feed and comes back, so it means “the peak since this token was last continuously listed”, not the peak of all time. And a pool that never had liquidity has no percentage to fall from, so it carries no drawdown either.

holderCount has the same problem liquidityUsd has: it is a level, and the rate is the signal. A token went from 347 holders to 13 over eight hours. The count said so only after the fact; the rate said so throughout.

Three fields carry the rate, each the percent change in holders over its own window:

fieldwindow
holderChange5mfive minutes
holderChange1hone hour
holderChange24htwenty-four hours

They were already being collected. The source has always sent them and the feed has always stored them, inside the nested stats object that the query parameters have no path syntax for. Lifting them onto the row is the whole change, so they cost nothing new to produce:

GET /api/v1/markets/feed?bucket=new
&where={"holderChange1h":{"lt":-20},"holderCount":{"gte":50}}
&sort=holderChange1h:asc
&fields=symbol,holderCount,holderChange1h,holderChange24h

Pair the rate with the count, as that example does. Three holders becoming six is +100% and means nothing. Below about fifty holders a single wallet moves the percentage by double digits, so the figure measures the size of the base rather than the behaviour of the holders.

On Hood these fields are always absent. The upstream publishes no holder rate in any window, so a Hood token carries holderCount and no change figure at all. This is worth saying separately from the general absent-is-not-false rule, because the gap is not random: a screen that read absent as zero would conclude that every token on one entire chain has a perfectly stable holder base. That is not a measurement, it is the absence of one. Solana rows also lose the figure whenever the feed falls back to its secondary source, which supplies no holder data.

GET /api/v1/markets/deployer/<address> answers what an address has launched and how those launches ended. Public, no credential.

{
"address": "6Yk...",
"priorLaunches": 14,
"graduated": 0,
"onCurve": 13,
"recovery": 1,
"graduationRate": 0,
"minLaunchesForRate": 3,
"scanned": 14,
"truncated": false
}

Fourteen launches and none of them reached a market is a complete decision on its own. No other field competes with it.

The same arithmetic already ran inside a full forensics report and was discarded outside it, so this was reachable only by asking for a report on one specific mint. That costs a chain walk and answers about a token. This is keyed by address and reads one index. Pass excludeMint to leave out the token you are already looking at, so the answer is what else this deployer shipped.

graduated, onCurve and recovery always sum to priorLaunches, because onCurve is computed as the remainder rather than counted separately.

The rate is withheld below minLaunchesForRate. One launch that has not graduated is not a zero percent record, it is no record, and a single data point rendered as a percentage is a confident way of saying nothing. Above the floor a genuine zero is reported, because that is the signal.

Only launches Candle recorded are here. No table anywhere stores a deployer for a token we did not launch, so for an external mint this returns an empty record. Empty is not a clean history. It means nobody knows, and the honest reading of an empty answer for a pump.fun token is that the question was not answerable rather than that the deployer is new.

Not graduated is not the same as went to zero. A token that never graduated may be an hour old. There is no price history for external tokens and none after graduation for our own, so survival is a claim this data cannot support. The field names say what is actually measured, and an agent wanting a rug rate has to bring something else to it.

A profile’s configuration, as something you can hold

Section titled “A profile’s configuration, as something you can hold”

GET /api/v1/agent/keys/<prefix>/config returns what a profile may do and every cap that bounds it, and nothing that identifies or authenticates it.

{
"version": 1,
"scopes": ["swap:write"],
"environment": "production",
"label": "Scalper",
"expiresInDays": 30,
"txLimit": { "usdMicros": 500000, "reset": "daily" },
"spendLimits": [{ "asset": "sol", "maxPerTxRaw": "50000000" }],
"lossLimits": { "maxConsecutiveLosses": 3 },
"walletScope": "selected"
}

A profile’s row mixes three unrelated things: a credential, an identity, and the rules its owner chose. Only the third is the strategy, and it was not an object you could hold. You could read it off a screen and retype it, which is why cloning a profile was manual and why there was nothing to diff, version or publish.

Three rules worth knowing before you use it

Section titled “Three rules worth knowing before you use it”

A config is not a credential. It is built from an allowlist of configuration fields, never from the row with a few fields removed. The key hash sits in the same row as the loss limits, and a serializer written the other way is one refactor away from putting a key hash in a file somebody shares.

Expiry travels as a duration. expiresInDays, not a date. An absolute expiry exported in September and applied in December is already expired when it lands, so the profile would be born dead and the failure would look like a bug rather than a stale file.

Wallet scope always exports as selected. Even when the profile it came from can use every wallet. Applying all would hand a profile spending access to every wallet on the receiving account, which is the largest privilege escalation in this feature. Narrowing on the way out is safe; widening on the way in is not, and a config that crosses an account boundary is the case this rule exists for. Widen it afterwards from a signed-in session if you mean to.

An omitted cap means no cap. Applying a configuration is a replace, not a merge. Absence never means “leave whatever is there”, because reading it that way is how a config that looks conservative produces an unlimited profile.

Stopping one profile without destroying it

Section titled “Stopping one profile without destroying it”

POST /api/v1/agent/keys/<prefix>/stop with {"paused": true} stops one profile. {"paused": false} starts it again.

There were two ways to stop an agent before this and neither fit the situation people are actually in. The account switch halts every profile at once, which is right when the account is the problem and far too blunt when one strategy is misbehaving and three others are fine. Revoking a key is precise but permanent: nothing anywhere un-revokes a key.

So the only precise tool was the irreversible one, and a person deciding between halting a working account and destroying a key forever hesitates. Hesitating is the one thing a kill switch must never cause.

A stopped profile’s own requests are refused with PROFILE_PAUSED. That is deliberately a different code from RISK_LIMIT_REACHED, which reads almost the same to a machine and means something quite different to a person:

codewhat happened
RISK_LIMIT_REACHEDthe profile stopped itself on a bound its owner set
PROFILE_PAUSEDa person decided to stop it

Telling an owner their agent “tripped a limit” when they pressed the button themselves would send them looking for a fault that does not exist.

Three rules worth knowing:

  • Session only. A paused key cannot resume itself, which is the whole point of a stop, and a compromised key cannot turn its own leash off.
  • A revoked key can be neither paused nor resumed. If resume worked on a revoked key, revocation would be reversible by another name and nobody could rely on it.
  • Both directions are idempotent, and the response says whether anything actually changed. A stop button gets pressed twice by someone who is not sure the first press landed, and an error on the second press reads as though the stop failed.

Everything else about the profile survives: its name, history, limits, wallet scope and keys are untouched, and it comes back with one call.

A trade response used to carry the Candle fee and the quoted output. At a $7 stake the fee is not the dominant cost. The gap between the price a quote promised and the price the fill delivered usually is, and nothing reported it.

Two fields on amounts now do:

fieldwhat it is
actualOutRawwhat the trade actually delivered, read off the confirmed transaction
slippageBpshow far that came in below expectedOutRaw, the quote taken at build time
{
"amounts": {
"amountRaw": "1000000",
"expectedOutRaw": "2500000",
"actualOutRaw": "2437500",
"slippageBps": 250,
"minOutRaw": "2375000",
"quoteAsset": "usdc"
}
}

Positive is adverse. Negative means the fill beat its quote, which genuinely happens and is deliberately not folded into a magnitude: an agent that read every deviation as a cost would learn to avoid the routes that were doing it a favour.

Both are Solana only, because that is where the fill can be decoded from balance deltas. Both are omitted rather than zeroed when nothing could be decoded, so "actualOutRaw" in amounts is a truthful test of whether a fill was measured. A zero would be a concrete claim that the trade moved nothing.

Worth knowing why this arrived late. Two rails execute a trade, and only one of them was reading the chain back. A linked payer’s /confirm decoded the real balance deltas; a main payer’s inline /build proved the trade landed with a signature status, which carries no balances, and recorded the build-time quote as though it were the fill. So the same trade placed two ways produced two different records, and the difference between them was exactly the number this section is about.

The same figure aggregates. A profile’s P&L now carries a friction block beside the result:

fieldwhat it is
feesUsdCandle fees across every counted fill
slippageUsdestimated USD given up to slippage, across the fills that could be measured
slippageBpsAvgsize-weighted average slippage, absent when nothing was measured
measured / unmeasuredhow complete that is

It is reported, never netted. Realized P&L is computed from what the chain actually filled, so the slippage is already inside it. Subtracting this figure as well would count the same loss twice and report a profile as losing money it never lost. What it answers is a different question: how much of the gap between the plan and the outcome was execution rather than the trade being wrong.

The average is weighted by size, not a plain mean. A 900 bps slip on a $2 trade and a 10 bps slip on a $500 trade do not describe the same execution quality, and a flat average says they do.

slippageUsd is an estimate and is labelled as one. It prices each fill’s shortfall against that fill’s own USD size, which is exact for a sell, where the shortfall arrives as fewer dollars, and an approximation for a buy, where it arrives as fewer tokens.

This is not the same thing as price impact. A pool can report 4 bps of impact and still cost 604 bps to round trip. Impact is a depth statistic and both sides of it pay the same proportional fee, so every fee cancels out of it. Ask for roundTripBps on a quote if you want the all-in cost before trading, and read slippageBps afterwards for what the fill actually did.

Wallet tracking already filtered the live tape, but that filter ran in the browser, so the watchlist only worked while the tab was open. That is the opposite of what a watchlist is for.

Register for tracked_wallet.traded on a webhook endpoint and a sweep pushes a notification when a wallet you watch buys or sells. There is nothing else to configure: the watchlist you already have is the subscription.

Two rules decide whether this is useful or annoying, and both are worth knowing:

  • You are never told the same thing twice. Each watched wallet carries a watermark, and only strictly newer trades qualify. Without it, a sweep that re-reads a window of recent activity would alert on the same trade every minute forever.
  • You are never told about yourself. People routinely watch a wallet they own, and being told that you just bought something is not information.

A wallet that trades constantly is capped per sweep, and the watermark clears the trades that were held back rather than deferring them. An alert about a trade from twenty minutes ago is not worth the notification it costs, so a backlog is skipped rather than worked through.

The same list does both. A wallet you track is watched for launches as well as trades, and tracked_wallet.launched fires when it deploys.

This is the stronger of the two signals by some distance. A token tells you about one launch; a deployer tells you what that person does, and the wallet behind three of last week’s runners is deploying again is worth being woken for in a way that no individual mint is. It is also the natural companion to the deployer prior-token history in the forensics report.

There is no second list to maintain: a deployer is a wallet, and asking somebody to keep two overlapping lists of addresses is how both go stale.

Launch alerts are capped harder than trade alerts. A deployer minting eight tokens in one minute is not eight pieces of news, it is one piece of news about a deployer minting eight tokens. Non-production launches never alert.

Both halves share one watermark per watched wallet, so you get one stream about that wallet rather than two that can disagree about how far you have been told.

The sweep runs about once a minute. It reads, decides and enqueues; it never touches chain state and never moves money.

POST /api/v1/agent/heartbeat (agent key, any scope). Optional body:

{ "nextExpectedBy": 1755000060000, "status": "scanning", "note": "waiting on a fill" }

A dead agent looks exactly like a quiet market. Nothing errors, nothing alerts, the dashboard shows the last trade from four hours ago, and noticing requires already suspecting. This is what makes that visible.

The agent sets its own deadline, not the platform. nextExpectedBy is when YOU say you will report again. Only you know whether you run every thirty seconds or every twelve hours, and one platform-imposed timeout would alert constantly on the first and never on the second.

A deadline already in the past is refused, not stored. An agent computing Date.now() + interval with a zero interval would report itself permanently overdue, and a screen full of false alarms is worse than no screen: the operator learns to ignore it, then misses the real one. More than a day out is refused too, because past that it is a calendar note rather than a liveness signal.

The owner reads GET /api/v1/agent/workers (session only) and sees one of five states per profile:

statemeans
liveChecked in within its own deadline.
latePast that deadline, inside a ten-minute grace. Usually a retry or a slow RPC.
missingPast the grace. Was reporting and stopped. This is the one worth acting on.
unknownChecked in but stated no deadline, so nothing can tell whether it is late.
neverHas never checked in.

never is not live, and unknown is not live either. An absent check-in reads naturally as “no news is good news”, and a screen built that way shows green for agents that never ran. A worker that reported without a deadline promised nothing, so it cannot be late; calling it healthy would let an agent silence its own monitoring by omitting one field.

Heartbeating is entirely optional. A profile that never calls this is reported as never and raises no alarm, because most profiles do not use it.

Send paper: true on POST /api/v1/trade/agent/build and the trade runs every admission rule a real one runs, records the quote, and never touches a wallet.

{ "clientTradeId": "paper-1", "mint": "So1...", "side": "buy", "amountRaw": "100000000",
"payer": { "type": "main" }, "paper": true }

A flag on /build rather than an endpoint of its own, deliberately. The entire value of a paper trade is that it was admitted by the SAME rules as a live one: the same market resolution, the same planner, the same wallet scope check, the same per-key spend caps, the same USD volume cap, and the same loss limits and circuit breaker. A separate endpoint would be a second copy of those rules, drifting quietly until paper stopped predicting anything about live.

What it does NOT do. No ledger row is written, no clientTradeId is burned on the live rail, no sign claim is stamped, no graduation watcher is armed, and nothing is signed or broadcast. The paper branch returns before the code that could do any of those exists.

The response always carries paper: true at the top level, so a caller cannot mistake it for a live one by failing to look for a field.

routewhat it answers
GET /api/v1/agent/keys/{prefix}/paperone profile’s paper P&L and its recent paper trades
GET /api/v1/agent/paperthe whole account’s paper arm, per profile (session only)
DELETE /api/v1/agent/keys/{prefix}/paperdiscard a profile’s paper history (session only)

Paper rows live in their own table. Nothing that reports live money can see them, so a paper fill can never appear in a profile’s public record. That matters more than it sounds: the value of a Candle trading record is that it is derived rather than asserted, and one paper fill inside it turns a checkable record back into a claim.

Clearing is session-only on purpose. Practice should be discardable, but the agent holding the key must not be able to erase its own bad paper run and re-report a clean one.

A paper trade records the quote, which is what the planner said the trade would return at the moment it was admitted. No order ever reached a market. So a paper figure is better than the real one by exactly the slippage, the priority fees, and every fill that would not have happened at the quote.

That gap is not a defect. It is the measurement paper mode exists to make: run a paper arm beside a live one, and the difference between them is your execution cost, which is otherwise unknowable. Both arms are accounted by the same engine so that the difference means only that, and not a difference in convention as well.

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.

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
transfer.executedA transfer out of the embedded wallet recorded, on either the inline or the build/submit pathchain, asset, amountRaw, from, to, destinationClass (own or approved), signature
withdrawal_address.addedAn address was added to the account’s withdrawal allowlist. Pushed so a consumer can alert on the account’s spend boundary wideningchain, address, label (when set)
withdrawal_address.revokedAn address was removed from that allowlistchain, address, label (when set)
tracked_wallet.tradedA wallet on this account’s watchlist bought or sold. Fired by a sweep rather than by the trade path: the wallet that traded is usually on somebody else’s account entirelychain, wallet, label (the watcher’s own name for it, when set), side, signature, tokenMint, tokenSymbol, usdAmount (absent when unpriced), at
tracked_wallet.launchedA wallet on the watchlist DEPLOYED a token. The stronger of the two signals: a token tells you about one launch, a deployer tells you what that person doeschain, wallet, label (when set), mint, symbol, at

The three transfer-rail events above were emitted from the day the rail shipped but were missing from the registerable set until 2026-09-10, so registration refused them and every one of those deliveries was enqueued and then dropped. If you tried to subscribe to them before that date and concluded they did not exist, they do now.

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 its own 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.

What was NOT checked, in a form you can branch on

Section titled “What was NOT checked, in a form you can branch on”

The report also carries a whole-report coverage object, in a closed vocabulary:

{ "covered": false, "reason": "external_launchpad", "launchpad": "pump.fun",
"checked": [], "unavailable": ["deployer_history","deploy_window","concentration",
"disclosed_wallets","risk_tier"],
"note": "This token launched on pump.fun, so Candle has no deploy-time record of it..." }

And it rides on the refusal, which is the case that matters. Measured against one real feed response, forensics could answer for 0 of 81 tokens: pump.fun 37, unlabelled 28, stonkfun 12, met-dbc 3, letsbonk.fun 1, Candle 0. Every one of those calls came back MARKET_NOT_FOUND and nothing else, which leaves a caller unable to tell we looked and it is clean from we did not look. Reading the second as the first is the cheapest wrong move in this system.

So MARKET_NOT_FOUND from this route now carries error.coverage. The code and the status are unchanged, so nothing that branches on the code breaks. reason distinguishes the two cases that used to be identical:

reasonwhat it meanswhat to do
external_launchpadA real, tradeable token that Candle did not launch, so there is no deploy-time record.Treat it as unchecked. Size for that, or skip it.
unknown_mintNothing is indexed at this address on this chain.Check the mint and the chain.

On a report that DID run, covered is true only when every check ran, and checked / unavailable name them individually. An agent gating a buy should require the specific checks it cares about to appear in checked, rather than reading covered: false as “close enough”.

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 address the market provider has never seen and no poolKey hint (reason: hood_unindexed), a Hood mint the provider errored on (hood_market_unavailable), or a Solana mint Jupiter cannot route (NO_ROUTES_FOUND) or does not recognize (TOKEN_NOT_TRADABLE) on the Pro/Max arbitrary path. Arbitrary Hood is not a 404 by construction; Pro/Max take the DEX race. See Tier gating above.no
TIER_REQUIRED403A Free or Believer key named a Solana or Hood mint with no Candle tokens row; Pro or Max is required to trade an arbitrary mint. See Tier gating above. Limit-order placement has its own Max-only TIER_REQUIRED; that is a different gate, documented in Limit orders.no
MARKET_NOT_TRADABLE409This side cannot execute right now: a curve mid-graduation with its pool not yet seeded, a Solana curve not found on-chain, or a Hood DEX build where no quoter found a routable pool. error.routing.reason says which, and retryable follows it.per retryable: a graduating curve and an empty race are retryable, a missing Solana pool is not
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

Every refusal from /build carries error.retryable, and a routing refusal also carries error.routing:

error.retryable is a SIBLING of error.routing, not a field inside it. That placement is contract: read it from error.retryable. Reading it from inside routing finds undefined and files every refusal as permanent.

FieldMeaning
reasonWhich refusal: hood_graduating, solana_graduating, no_candidates, hood_unindexed, hood_market_unavailable, solana_pool_missing, market_unknown, quote_pair_unknown, exclusive_window.
adaptersAttemptedHood DEX venue: which quoters were asked, in call order (kyber, uniswap).
kyberAttemptWhat the aggregator did: quoted, no_route, unavailable, skipped.

The distinction that matters on the Hood DEX venue: an aggregator no-route and an aggregator outage are never the terminal refusal. Both fail over to Candle’s in-house race, so the reason you receive describes what the RACE found, and kyberAttempt is the only field that says the aggregator lagged. A no_candidates refusal is retryable because a fresh launch’s deepest pool is routinely one the race cannot express yet and the aggregator indexes minutes later; a hood_unindexed one is not, because nothing discovers a pool the market provider has never seen. hood_market_unavailable is its neighbour and also not retryable: the provider ERRORED on this mint, twice, while serving others, so the token may well be real and the only source that could describe it is broken for that address. Treat it as you treat hood_unindexed, and do not keep asking.

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?/permit2Approval?/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.

Say this plainly, because it is the limit of what a stop here can promise. Candle evaluates the condition and flips the status; it never executes. So a stop placed here is a reliable alarm, not a reliable exit: if the agent that placed it is not running when it fires, nothing sells. The order.triggered webhook makes the alarm reachable without a polling loop, which narrows the window, but a consumer still has to act on it. Server-side execution is a separate thing that does not exist on this rail today, and no wording here should be read as implying it does.

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
}

trigger decides the direction, and it is optional: absent means limit, which is what every order placed before this field existed is.

triggersidefires whenwhat it is
limitbuyprice at-or-below targetPricebuy the dip
limitsellprice at-or-above targetPricetake profit
stopsellprice at-or-below targetPricestop-loss
stopbuyprice at-or-above targetPricebreakout entry
trailing_stopeitherprice retraces trailBps from the best price seena stop that follows

The stop rows are the ones that did not exist before. Under limit semantics the side alone decides the direction, so a sell could only ever fire on the way UP: “sell if this falls to 0.7” was not merely missing, it was unrepresentable, and the only way to honour a stop was to stay online and sell by hand.

A trailing_stop takes trailBps instead of targetPrice, and sending a target with one is an error. Its stop is derived from a watermark: the highest price seen since placement for a sell, the lowest for a buy, seeded at the price when the order was placed. The watermark ratchets one way only and never retreats, so the stop rises with a winning position and holds where it got to. A trail is between 50 and 9000 basis points; tighter than half a percent is noise rather than protection, and it would sell on the spread.

A stop on the wrong side of the current price is refused, not accepted. A stop-loss typed above the price is a valid order under every other rule, and it fires on the very next sweep, exiting the position immediately at market. It is nearly always a typo. A LIMIT order on the wrong side is allowed, because that is simply an order to transact now.

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.