Skip to content

Headless launch API

POST /api/v1/launch/headless launches a token fully server-side, no UI, no browser wallet. Built for autonomous agents and bots, on Solana or Hood (Robinhood Chain).

v2 replaces the old shared LAUNCH_BOT_API_KEY with self-serve, per-account agent keys, adds dual-chain support, idempotent retries, and a dry-run mode.

Once a token exists, trade it (buy or sell against an existing market) via the separate POST /api/v1/trade/agent/build + /confirm rail, documented in Agent trading API. It reuses this doc’s Auth, tiers, and Linked wallets sections unchanged.

There are three ways for an agent to launch. Only the first is open to every plan.

PathPlansWho creates and paysBuy at launch
Headless launch: POST /api/v1/launch/headless, SDK launch(), MCP candle_launch_token and candle_launch_and_seedFree, Pro and MaxThe account’s embedded wallet, signed by Candle on the key owner’s behalfA dev buy (buyAmount) in the launch transaction on Solana, up to 0.5 SOL by default; a follow-up transaction on Hood
Self-signed launch: /launch/self/build and /confirm, SDK selfLaunch(), CLI candle launch. Dry run: /launch/self/dry-run, SDK dryRunSelfLaunch()Pro and MaxA linked wallet or a TEE wallet, signed by the agentA linked wallet may carry a dev buy, same ceilings. A TEE wallet launches with no buy, and the first buy is a separate swap, unless the server has TEE_LAUNCH_BUYS_ENABLED on: then it may carry one too, same ceilings, reserved against the bound key’s spend window (CLI candle launch --buy). The flag is off by default
Atomic launch: /launch/atomic/build and /submit, SDK launchAtomic()Pro and Max, Solana onlyCreator and each buyer are the embedded wallet or a linked wallet. A TEE wallet only where the server has TEE_LAUNCH_BUYS_ENABLED on (off by default): as creator with allowLaunch, as buyer without it1 to 4 firstBuys legs, each its own transaction, in one Jito bundle with the launch: same block, all land or none do. The dev-buy ceiling does not apply; the key’s spend limits do

“A dev buy in the launch transaction” and “an atomic launch” are different things. The first is an optional buy from the embedded wallet (same transaction on Solana, a best-effort follow-up on Hood) and needs no plan. The second is a bundle of the launch and 1 to 4 first-buy transactions; payers are the embedded or linked wallets and may repeat. It needs Pro or Max. The CLI’s candle launch is the self-signed path, so it needs Pro or Max; on Free, launch through the MCP tools, the SDK’s launch() or the REST route.

The headless route has no wallet field. A body that carries payer, wallet, linkedWalletId or firstBuys is refused with 400 VALIDATION_FAILED and the launch does not proceed. The headless dry run’s launchWallet is the embedded wallet. To launch from a linked wallet use the self-signed route (it has its own dry run, see Dry run), and for first buys use the atomic route.

A key that denies the embedded wallet (the setup recommended for a bot) cannot use the headless route, or an embedded-wallet leg of the atomic route (EMBEDDED_WALLET_NOT_PERMITTED). It launches from a TEE wallet bound to it, through the self-signed route, once the account owner has turned on that wallet’s allowLaunch: see TEE wallets are linked wallets. Where the server has TEE_LAUNCH_BUYS_ENABLED on, that launch may carry a dev buy, and the atomic route also takes its TEE wallets as creator and buyers. Each TEE wallet’s spend is reserved against the key’s USD spend window before anything is signed. The flag is off by default; while it is off, a TEE wallet’s buyAmount must be 0 and the atomic route refuses it.

On Hood, a TEE self-signed launch with a dev buy sends its platform fee leg before the separate dev-buy transaction. If that fee lands and the buy reverts, the fee is not returned; the sender also bears the reverted transaction’s gas. This disclosure applies to the flag-gated TEE path, whose launch-buy flags are off in production as recorded on BE-897 on 2026-10-13, not to Hood embedded-wallet headless buys, whose fee follows a successful buy.

Every headless launch is signed and paid for by the key owner’s own embedded wallet, not Candle’s: it pays the network fee, the account costs, and the dev buy, and is the on-chain creator. A launch costs Candle nothing on net.

The account costs reach Candle by a detour worth knowing about. The deployed program creates the pool, mint, vaults and metadata with the platform keypair as their rent payer, and that cannot be changed without an on-chain upgrade, so the launcher reimburses the platform in the same transaction instead. See Operational notes for the amount and why it is ordered first. This applies to every launch, UI and agent alike.

A headless launch needs a wallet that can sign server-side with no human in the loop, and that is only possible for a wallet Candle actually holds the keys to: a Privy embedded wallet, the kind an account gets by signing up with Google, X, or email. A self-connected wallet (Phantom, MetaMask, etc.) can never be delegated, so an account that only ever connected one of those cannot use agent access at all, on either chain, until it also signs up through Privy and gets an embedded wallet.

Getting a working key takes three steps, in order:

  1. Get an embedded wallet. Sign up (or add a login method) with Google, X, or email. An account that already has one for a chain skips this.
  2. Delegate agent access. From the profile’s Agent access panel (or directly via @privy-io/react-auth’s useDelegatedActions().delegateWallet), grant Candle’s backend permission to sign with that embedded wallet on the account’s behalf. This is per chain: an account can delegate its Solana wallet, its Hood (EVM) wallet, or both independently. Revocable at any time, from the same panel.
  3. Enable agent features and issue a key, as below. POST /api/v1/agent/keys checks delegation and refuses to issue a key for an account with nothing delegated (AGENT_WALLET_NOT_DELEGATED, see Errors).

Every launch re-resolves delegation fresh from Privy; it is never cached or trusted from an earlier check. Revoke delegation and the very next launch attempt fails closed (DELEGATION_REVOKED) even though the key itself stays valid, unrevoked, and usable again the moment delegation is re-approved.

This is a breaking change for keys issued before this shipped: an existing key cannot launch again until its owner delegates at least one chain. There is no grandfathering and no platform-funded fallback.

Every account manages its own agent keys from the Candle site (the Agent Console at /agents, Agents tab) or directly against the API:

  1. POST /api/v1/agent/enable (authed, requires a linked Solana wallet) with { "enabled": true } to turn on agent features for the account. This step alone does not require delegation; delegation is checked next, when a key is actually issued. Agent features cannot be switched off from the account: { "enabled": false } is refused (403); contact support.
  2. POST /api/v1/agent/keys (authed) issues a key, but only once the account has delegated agent access for at least one chain (see Agent access above); otherwise AGENT_WALLET_NOT_DELEGATED (403). The plaintext key is returned only in this response; store it now, it cannot be recovered later. The response (and every row GET /api/v1/agent/keys returns) carries launchChains: the chains ("solana" and/or "hood") delegated at issuance. It is a display snapshot only, not the authority checked at launch time — delegation can be revoked on Privy’s side at any moment, so every launch re-resolves it live rather than trusting this field.
FieldNotes
environmentproduction (default) or test. See below for the test restriction.
labelOptional display name, 1 to 64 characters after trimming. Renameable later via PATCH. With none, the dashboard derives a name from the key’s capabilities.
expiresInDaysOptional integer, 1 to 365. Converted to an absolute expiresAt at creation. Mutually exclusive with expiresAt.
expiresAtOptional absolute epoch ms, strictly in the future. Fixed at creation: there is no renewal call and no PATCH field for it. Omit both fields for a key that never expires (the default). Once past, every call with that key returns KEY_EXPIRED (401).
txLimitOptional { "usdMicros": <positive integer>, "reset": "never" | "daily" | "weekly" | "monthly" }. Caps this key’s cumulative USD trading volume, including launch dev buys and their platform fees, over that window. A token sell counts toward the cap and is never refused by it. Embedded-wallet headless launches reserve buy plus fee before signing; exceeding the cap is KEY_LIMIT_REACHED (403), and unavailable valuation or usage fails closed. Linked self-signed dev buys are metered into the window; enforcement stays advisory because Candle does not sign them.
refOptional referral code. Attributes the account to that referrer once, best-effort, and never blocks issuance. Only an account created in the last 24 hours can be attributed. A referred account then pays 10 percent less on the platform fee for every trade (launch dev buys are not discounted).
embeddedWallet"allowed" or "denied": whether the key may spend the account’s embedded wallet. Omitted, the key is created "denied", and a denied key cannot launch headlessly (the launch signs with the embedded wallet), trade with payer: {"type": "main"}, or transfer from it: each is refused with EMBEDDED_WALLET_NOT_PERMITTED (403). Send "allowed" for a key that launches. The response echoes the stored value. The owner can change it later with PUT /api/v1/agent/keys/{prefix}/embedded-wallet. Keys issued before this field existed are "allowed".
scopesSubset of launch:write, launch:read, account:read, activity:write, swap:write, transfer:write, transfer:bound, lp:write. Defaults to launch:write, launch:read, account:read, activity:write; swap:write, transfer:write, transfer:bound and lp:write are opt-in only and never granted by omission, since each moves real funds through the owner’s own wallet on every call. transfer:bound (2026-09-24) is the Withdraw level: it requires transfer:write beside it (a list naming it alone is refused), and it is the one scope that admits a TEE wallet as the ORIGIN of POST /api/v1/agent/transfer/build, to that wallet’s own pinned vault or to another of the account’s wallets the owner linked while signed in or marked trusted; the device-authorization flow refuses it for now. account:read (2026-09-22) reads the whole account across every profile — GET /api/v1/agent/trades unscoped, /agent/books, /agent/paper, /agent/transactions, /agent/workers, every /agent/keys/:prefix/* read, /agent/tier, /agent/wallets — and writes nothing: it is the dashboard’s Read preset, and Write is that plus launch:write, launch:read, activity:write, swap:write, transfer:write. A key issued before account:read existed keeps its stored scopes until revoked; every account read still admits launch:write for exactly that reason, but the four account-wide reads (/books, /paper, /transactions, /workers) and the unscoped tape need account:read or a session. launch:read is enforced by the launch-jobs endpoint (GET /api/v1/launch/headless/jobs/:clientLaunchId); a key without it cannot poll job status. swap:write gates the one-shot swap endpoint, the agent trade rail (POST /api/v1/trade/agent/build + /confirm + /submit), the linked-wallet sign relay (POST /api/v1/agent/wallets/:id/sign), linked cross-chain swaps (POST /api/v1/agent/swap/build + /submit), and atomic launches (POST /api/v1/launch/atomic/build + /submit, which need both launch:write and swap:write), see Agent trading API.

Send the key on every headless request as the x-api-key header:

x-api-key: cndl_live_... # production key
x-api-key: cndl_test_... # test key

GET /api/v1/agent/keys lists every key the account owns (revoked included). Each row also carries a usage object (todayUsdMicros, weekUsdMicros, monthUsdMicros, lifetimeUsdMicros, days30UsdMicros, tradeCount30, launchCount30, lifetimeLaunches) alongside a top-level usageSince. Metering started 2026-08-15 and there is no backfill: a key used heavily before that date reports a lifetime total that understates its real history.

DELETE /api/v1/agent/keys/:prefix revokes one.

PATCH /api/v1/agent/keys/:prefix edits exactly one of { "label" }, { "txLimit" }, or { "clearTxLimit": true } per call; sending more than one is VALIDATION_FAILED. Expiration is deliberately absent here, since it is fixed at creation.

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

PUT /api/v1/agent/keys/:prefix/limits sets that key’s own per-asset spend caps, and GET /api/v1/agent/keys/self/limits reads back the effective caps for the calling key — the “what may I spend right now” check an agent should make before a large trade. See Account spend limits below for how a key’s caps override the account’s.

All of these sit on the same auth as POST/GET/DELETE /keys: a Privy session or a CLI device token, never an x-api-key. Full field tables and worked examples live in the Key management section of Agent trading API.

Every account is on a plan, and the plan sets a key’s caps, its trade fee and what it can do. The plan table is served, not fixed: GET /api/v1/agent/plans (no key) returns the table this deployment enforces and charges, overrides included, with one boolean per plan gate under capabilities. candle plans, the SDK’s getPlans() and the MCP tool candle_get_plans read it, and GET /api/v1/agent/tier carries the same table as planTable beside the account’s own plan. Quote from there rather than from this page.

Candle’s deployments (staging and alpha) serve three plans, Free, Pro and Max, at these defaults:

FreeProMax
Pricefree$50 a month$200 a month
Agent trade fee1%0.5%0.25%
Perps builder fee0.1%0.1%none
Requests per minute30300600
Launches per day5501,000
Uploads per minute103060
Linked wallets0101,000
Trade Candle-launched tokensyesyesyes
Trade base assetsyesyesyes
Base-pair swaps and own-wallet bridges (when enabled), no Candle feeyesyesyes
Sell tokens not launched on Candleyesyesyes
Buy tokens not launched on Candlenoyesyes
Hyperliquid perps (when enabled)yesyesyes
Launch from a linked or TEE wallet (self-signed)noyesyes
Atomic launch: launch + 1–4 first buys in one bundlenoyesyes
Create linked walletsnoyesyes
Import linked walletsnoyesyes
Limit ordersnonoyes
Quant deep analyses (Max)nonoyes

A capability marked yes is what the plan allows. It is subject to the deployment’s own switches (perps and own-wallet bridges each have one) and to the wallet, scopes and setup the feature needs.

The Quant deep analyses row is Max-level Quant (the deep run_analysis studies). Pro also has Quant, on Telegram and in the Candle chat with token and wallet data and fresh reads, but Quant grants that from the plan name, not from this row. Quant’s daily message caps are Quant’s own configuration, not this table: on 2026-10-16 they were 50 a day on Free (docs chat only), 100 on Pro and 500 on Max.

How each plan is reached:

PlanHow
Freeagent features enabled
Pro$50 a month, or 500k CNDL staked or 1M held (live, no grace)
Max$200 a month; a first Pro purchase includes Max for the days promoMaxDays reports while the promotion runs (30 on 2026-10-07)

Spend limits are not a plan perk: every key starts fully unlimited, and any plan can cap its own keys (see Spend limits below). Linked wallets are covered in full in the Linked wallets section below; an account whose plan has 0 linked wallets (Free) cannot import or link a wallet at all (TIER_REQUIRED). It can still launch: a headless launch uses the account’s embedded wallet, which is not a linked wallet and needs no plan.

The launch cap is per account (multiple keys never multiply launches) and resolves as max(key row, plan floor), so an individually raised key is never lowered. The daily launch cap is a UTC calendar-day counter: failed launches are refunded unless a Hood headless launch paid for a fee-splitter deploy. That launch keeps its admission day’s slot, even on a definitive failure. A same-clientLaunchId retry on that UTC day uses the retained slot; a retry on a later day takes that day’s slot and must pass its cap. A gate rejection returns only the slot that retry took. In-flight launches count, and the window resets at 00:00 UTC. Pro through CNDL is re-evaluated continuously, with no grace window: once the balance drops below both thresholds, the account is Free on the next check (within 5 minutes) unless a paid plan covers it. Max is granted for a paid period, or manually with an expiry via POST /api/v1/admin/agent-tier (admin-only).

There is no Believer plan. A key issued before the three-plan launch while its owner held a Believer NFT carries a daily launch cap of 20 on its row; on a Free account that key enforces 5 a day, and it reads as its account’s plan at that plan’s fee. New keys are issued at the Free defaults whatever NFT the owner holds. Nothing is rewritten on the key row.

The live plan’s staking/holding thresholds are configurable server-side:

VariableDefaultWhat it is
AGENT_TIER_PRO_MIN_STAKED_CNDL500000CNDL staked (sCNDL LP, converted at the pool ratio) at or above which an account qualifies for Pro.
AGENT_TIER_PRO_MIN_HELD_CNDL1000000CNDL held (spot balance) at or above which an account qualifies for Pro, independent of staking.
AGENT_TIER_GRACE_MS172800000 (48h)How long an account keeps Pro after its balance drops below both thresholds, before falling back to Free. Not read while the three plans are served, so the grace window is 0.

An account may hold at most 12 active keys. Revoke an unused one before issuing a 13th; POST /api/v1/agent/keys returns VALIDATION_FAILED (403 is used only for “agent features disabled”) once the limit is hit.

A test-environment key authenticates as the real account, but the server scopes what it can launch: POST /api/v1/launch/headless rejects a test-key request whose visibility is not test or hidden (VALIDATION_FAILED, field visibility). Use a test key for integration testing so nothing lands in the production feed by accident.

The whole launch:write scope is gated on HEADLESS_LAUNCH_ENABLED=true server-side; when it is unset or not exactly "true", every call (including dry-run) returns LAUNCH_DISABLED (503), before the key is even parsed.

Pro and Max accounts can attach additional wallets to their Candle identity, beyond the embedded wallet used for headless launches. There are two ways to add one, and they grant different capabilities:

  • Import hands Candle a wallet it did not previously manage and makes it spend-capable: an agent signer and a policy (the fixed structural backstop — see The wallet policy below) are attached so the agent key can trade with it.
  • Link-existing attaches a wallet already on the caller’s own Privy account for attribution only: no signer, no spend authority, nothing an agent could use to move funds.

Both kinds of linked wallet participate in trade attribution (see below) once linked, and both are capped per plan (see the Linked wallets row above) and revocable at any time. Free accounts cannot use either path (TIER_REQUIRED, 403).

GET /api/v1/agent/wallets lists them. Each row carries walletKind: tee (a TEE wallet bound to one key), imported (spend-capable, from the import flow) or linked (attribution-only, cannot sign). Branch on walletKind and allowLaunch; profile is still served but its values are internal names, not part of the contract. See Agent wallets.

Importing a wallet is a three-step, ciphertext-only exchange driven by the SDK’s CandleClient.importWallet({ chain, address, privateKey, signerPublicKey, label? }):

  1. POST /api/v1/agent/wallets/import/init (agent key, launch:write, Pro/Max only) returns Privy’s HPKE receiver public key (encryptionPublicKey) for the requested chain (“solana” or “evm”) and address.
  2. Client-side, never sent to Candle: the SDK’s encryptWalletKeyForImport decodes privateKey to its raw bytes (hex for evm, base58 for solana, matching Privy’s own reference decode byte-for-byte) and HPKE-seals those bytes to encryptionPublicKey — RFC 9180 Base mode, DHKEM(P-256, HKDF-SHA256) / HKDF-SHA256 / ChaCha20-Poly1305, with a fresh ephemeral sender keypair per call. generateSignerKeypair() in the same module can mint the P-256 signer keypair the next step needs; only its public half is ever sent anywhere.
  3. POST /api/v1/agent/wallets/import/submit posts only ciphertext, encapsulatedKey, and signerPublicKey (never the private key itself, plaintext or otherwise). The server then, in order: registers a 1-of-1 Privy signer quorum from signerPublicKey; creates the fixed backstop policy (see The wallet policy below); calls Privy’s wallet-import endpoint with the ciphertext, attaching the signer and the policy ATOMICALLY in that one call (a signer is never registered without a policy); and only then links the wallet in Convex, last. If the Convex link fails or is rejected after the Privy import already landed, the policy is neutralized (cleared to default-deny) before the error is returned; the wallet itself stays on the caller’s own Privy account either way, since it is theirs regardless of whether Candle tracks it.

The key’s cap is strict per rail (embedded, linked, TEE), not as one combined cap across rails; hard enforcement applies to Candle-controlled signing, while imported linked-wallet checks remain advisory. This is the owner’s BE-897 decision of 2026-10-13.

Per-key only (since 2026-08-23). Spend caps belong to the API key that spends under them — the account-wide main/linked scopes and their GET/PUT /api/v1/agent/limits routes are retired. A key’s per-asset caps are set after issuance with PUT /api/v1/agent/keys/:prefix/limits (POST /keys takes no per-asset caps; its txLimit, or the CLI’s --tx-limit, is the separate USD volume cap); a key with no cap for an asset is uncapped for it, with no account fallback. A SPEND_LIMIT_EXCEEDED response carries spendLimit.limitSource (always "key" now) alongside keyPrefix, asset, capDecimal, and attemptedDecimal. Only a Privy session may loosen a key’s cap; an agent key may only tighten (LOOSEN_REQUIRES_SESSION, 403). One class skips this gate entirely: base-pair swaps between the platform’s own base assets are never spend-capped (see Agent trading API).

A limit is { "asset": "sol" | "usdc" | "cndl" | "eth" | "usdg", "maxPerTxRaw": "<raw units, decimal string>" }. A single key’s array may mix Solana and EVM assets. "0" is rejected as a value — a spend limit bounds a transfer, it does not freeze an asset. A duplicate asset id within one array is rejected outright (VALIDATION_FAILED).

GET /api/v1/agent/keys/self/limits (agent key, any scope) returns the calling key’s own caps — { "success": true, "keyLimits": [...] | null } — so an agent can self-throttle before it ever hits SPEND_LIMIT_EXCEEDED. null means unlimited.

The server-side gate is authoritative; the Privy wallet policy is a fixed backstop, not your per-asset caps. The spend-limit check, called at the trade, launch, and transfer build gates before anything is ever built, is the ONE place a key’s spend limits are enforced. Every agent-controlled signer — the account’s own delegated launch wallet and every spend-capable linked wallet — starts from a permissive, match-all Privy policy: unlimited by default, no caps until a key carries one. The sign relay is build-bound (POST /api/v1/agent/wallets/:id/sign signs only a transaction Candle itself built and staked out via a single-use claim — see “The relay signs only API-built transactions” in Agent trading API), which is what lets the wallet’s own policy stay limits-independent.

The wallet policy: a fixed backstop, not per-asset caps

Section titled “The wallet policy: a fixed backstop, not per-asset caps”

Every spend-capable linked wallet’s Privy policy is now a fixed, limits-independent structural backstop: the same permissive match-all base as before, plus exactly two kinds of DENY, neither of which reads the account’s own caps at all:

  1. A platform ceiling, not a user setting: AGENT_WALLET_CEILING_LAMPORTS (default 50 SOL) / AGENT_WALLET_CEILING_WEI (default 10 ETH) bound the wallet’s worst-case blast radius even if every upstream layer somehow failed at once. Generous by design — a wallet actually used for trading should never brush against it under normal operation.
  2. A narrow Approve/ApproveChecked/SetAuthority DENY (Solana only): the instruction shapes that can hand away spend authority or reassign account ownership outright, blocked unconditionally, chain-wide, regardless of whether any spend limit is set at all.

DELETE /api/v1/agent/wallets/:id (see Revocation below) reports whether it could clear a revoked wallet’s leftover policy via policyNeutralized: true/false when the row carried one, omitted entirely for an attribution-only link that never had a policy to begin with.

What a cap actually bounds. A key’s caps bound the DECLARED amount of a buy, checked server-side against amountRaw (trades) or buyAmount (launches) before anything is built — never a property Privy’s policy engine evaluates any more. For a Solana buy this is airtight: the capped amount is a leg bundled directly into the one transaction Candle builds and the build-bound relay will sign, so there is no separate step where a larger amount could sneak in. For a Hood dev buy built by /launch/self/build, it is narrower: the dev buy is the agent’s own standalone follow-up transaction, sent entirely outside this API (see “Linked-scope spend gate: what it actually enforces” under Self-signed launches below), so the /build-time check can only ever bound what the agent DECLARES, not what it actually sends afterward.

Separately, the wallet’s own Privy backstop policy (see above) carries its own platform ceiling DENY, which genuinely does inspect a submitted transaction’s shape — but only a TOP-LEVEL instruction’s shape, never what a program instruction moves internally via CPI: live-verified against a real Privy app (2026-08-10). A throwaway wallet with a low DENY cap signed a real, unbroadcast Jupiter swap moving several times that amount, because the only instruction touching the swapped asset was a top-level call to Jupiter’s own aggregator program, which CPIs the actual transfer internally; the identical wallet/policy denied a plain top-level transfer over its cap in the same run, confirming the DENY genuinely was in force. Any program-routed spend (a DEX swap, a curve buy, any other CPI) is invisible to the wallet’s own Privy policy, for any asset, regardless of the platform ceiling — which is exactly why the account’s own caps are enforced server-side now rather than relying on the wallet policy to catch an over-cap amount by inspecting the transaction it is asked to sign.

Attribution-only linking of an existing wallet

Section titled “Attribution-only linking of an existing wallet”

POST /api/v1/agent/wallets (agent key or Privy session, Pro/Max) links a wallet Candle’s own Privy project already manages on the caller’s account. The address is verified against the caller’s own Privy account (walletOnUser) before it is trusted, so a caller can never attribute a wallet it does not own. No signer or policy is created on this path — attaching spend authority to a wallet Candle did not import needs the owner’s consent, which only the import flow above gets structurally — so a link-existing row has nothing to neutralize on revoke.

Every linked wallet, spend-capable or attribution-only, then counts toward trade attribution: POST /activity/report’s Solana path tries the account’s primary wallet, then each linked Solana wallet, first match wins; its Hood path accepts a swap signed by any linked EVM wallet, merged with Privy’s own account wallet list. The Hood curve-trade ingester (the keeper that catches trades made outside Candle’s own UI) runs the same identity hierarchy in reverse: given a trade’s raw EVM address, a linked wallet wins first, then a primary-EVM user match (the two are disjoint by construction, since linking rejects an address that is already someone’s primary wallet). Both paths read through a 60-second per-account cache rather than hitting Convex on every report or ingested trade.

DELETE /api/v1/agent/wallets/:id (agent key or Privy session) tombstones the linked-wallet row; a foreign or unknown id 404s the same way either caller sees it. The tombstone alone is authoritative for attribution going forward — a revoked wallet stops matching in the attribution hierarchy above — but when the row carries a spend policy (an imported wallet, not a link-existing one), revocation also neutralizes that policy on Privy: its rules are cleared, so the agent signer falls back to Privy’s engine default of implicit deny on everything. That neutralize step is best-effort and logged on failure rather than blocking the response, since the tombstone is what Candle’s own attribution relies on either way, and its outcome is reported back in the response as policyNeutralized: true/false so a failed neutralization is visible and retryable — the DELETE itself is idempotent. The field is omitted entirely for an attribution-only link, which never had a policy to neutralize. Revocation clears the account’s attribution cache immediately, so a just-revoked wallet never attributes a later trade.

Moving a TEE wallet to another key (rebind)

Section titled “Moving a TEE wallet to another key (rebind)”

A promoted TEE wallet is bound to exactly one API key (boundKeyPrefix, written at import). The account owner can move it to another key on the same account without touching the funds, the key material or the Privy signer:

  • POST /api/v1/agent/tee-wallets/rebind (Privy session or device token; the same keysAuth pair that manages keys). An agent API key can never rebind a wallet, its own included: a stolen key could otherwise push its wallets onto a looser key or pull another key’s wallet.
  • GET /api/v1/agent/tee-wallets/rebinds?walletId=&limit= (same auth) lists the account’s rebinds, newest first, with the device prefix that did each one.

Preview first, then commit with what the preview showed:

{ "dryRun": true, "toKeyPrefix": "Ab3dEf9h", "wallets": ["tr-01", "7xKX…gAsU"], "labelPrefix": "cn-" }

wallets takes ids, addresses or row labels, resolved against the caller’s own TEE rows; a label that matches more than one wallet is refused (REBIND_WALLET_INVALID, reason: "ambiguous", with matches listed). labelPrefix adds every active TEE wallet whose label starts with it. A preview runs every check below and moves nothing.

{ "toKeyPrefix": "Ab3dEf9h", "walletIds": ["k57…", "k58…"], "expect": { "k57…": "B6P-TSRs", "k58…": "B6P-TSRs" } }

A commit takes only concrete ids and expect, the binding shown for each id. It is a compare-and-swap: if another device or session rebinds in between, the commit is refused as REBIND_STALE instead of silently overwriting. Selectors on a commit are VALIDATION_FAILED.

What must be true, checked inside one Convex transaction (all or nothing, 1 to 200 wallets, else REBIND_TOO_MANY):

  • The target key is on this account (a key on another account reports not_found), not revoked, not expired, a production key, and holds swap:write (REBIND_TARGET_INVALID with reason). launch:write is not required: a wallet with allowLaunch bound to a key without it simply cannot launch. A paused key is allowed and reported as paused: true.
  • Each wallet is an active TEE wallet at verified-active remote authority (REBIND_WALLET_INVALID with reason: not_found, not_tee, inactive, authority_not_verified). A wallet already bound to the target is unchanged, not an error.
  • No claim that could still move funds. A build for the wallet still inside its 30-minute window is REBIND_BUILD_OPEN (409) with walletIds and retryAfter; stop the old key and run it again after that. A sign the relay forwarded whose payload is not yet confirmed, failed or provably expired (block height past its lastValidBlockHeight) is REBIND_FORWARD_OPEN (409), with no clock: reason: "unexpirable" means the blocker is a Hood claim, which has no expiry. The server reads the chain for the wallets in the request before deciding; if that read needs another call it answers REBIND_RECONCILE_INCOMPLETE (503, retryable) and nothing changed.
  • The target’s wallet scope has room: a selected-scope key gets one assignment row per moved wallet, and growing past 1,000 (the Max plan’s linked-wallet cap) is REBIND_SCOPE_FULL (409, max); a rebind that adds no row is never refused for size. The way out is all-wallets scope, which also opens every non-TEE linked wallet on the account to that key; TEE wallets stay bound regardless.

What does not change: the address, privyWalletId, the signer quorum, the policy, the remote evidence, everRemoteExposed, vaultDestination (the exit pin), allowLaunch and the funds. The mutation writes boundKeyPrefix and nothing else on the row. Nothing is read from or written to Privy: the quorum holds a per-wallet signer and the policy is built from the chain alone, so nothing Privy holds is tied to the importing key.

From the commit, the old key gets key_not_bound on its next build and sign, GET /wallets/trading lists the wallet for the new key only, and a claim stamped under the old key is refused at sign time even if it was stamped a moment before. The old key’s reservations and committed volume stay counted against the old key; the wallet’s trades now draw on the new key’s caps, budget and loss limits.

Caps are a trade-time gate, reported but not refused. The response carries the target’s readiness per quote asset:

{
"success": true,
"dryRun": false,
"toKey": { "keyPrefix": "Ab3dEf9h", "label": "tr-01", "paused": false, "walletScope": "all",
"tradeReady": { "sol": true, "usdc": false }, "hoodTradeReady": { "eth": true, "usdg": true },
"missingCaps": ["spendLimits.usdc"], "launchScope": false },
"rebound": [{ "id": "k57…", "address": "…", "label": "tr-01", "fromKeyPrefix": "B6P-TSRs", "allowLaunch": false, "chain": "solana", "auditId": "…" }],
"unchanged": [{ "id": "k59…", "address": "…", "label": "tr-02" }]
}

tradeReady.sol is true unless the key sets a raw SOL cap of zero (spendLimits); the same for usdc, and for eth and usdg under hoodTradeReady, the quote assets of a Hood (EVM) TEE wallet. Both limits are optional: a key with no txLimit and no raw caps can trade. missingCaps lists the zero caps that stop a trade (spendLimits.sol, spendLimits.usdc, spendLimits.eth, spendLimits.usdg). Each rebound row’s chain (solana or evm) says which pair applies to that wallet. auditId is absent in a preview. Every rebind writes one audit row per moved wallet (actor, actorDevicePrefix for a device token, a shared batchId).

The relay signer does not move. The new key’s agent can trade the wallet only from the machine that promoted it, which holds that wallet’s signer. Rebinding is not incident response: after a key leak the path is still stop, sweep, new wallet.

Candle’s server never receives, stores, or logs a linked wallet’s plaintext private key at any point in the import flow. The key exists in memory only inside the SDK’s importWallet() call: it is decoded and HPKE-sealed locally, and only the resulting ciphertext, encapsulated key, and a caller-generated signer public key ever leave the calling process. And every signer Candle ever registers for a linked wallet is attached with a spend policy in the same atomic Privy call that creates it — there is no window in which an agent-controlled signer exists on a wallet without an attached policy.

VariableDefaultWhat it is
AGENT_TIER_LINKED_WALLETS_PRO10Max active linked wallets a Pro-tier account may hold at once.
AGENT_TIER_LINKED_WALLETS_MAX1000Max active linked wallets a Max-tier account may hold at once.
AGENT_WALLET_CEILING_LAMPORTS50000000000 (50 SOL)Platform-set blast-radius ceiling on every spend-capable Solana linked wallet’s Privy backstop policy — not a per-account cap, see “The wallet policy: a fixed backstop, not per-asset caps” above.
AGENT_WALLET_CEILING_WEI10000000000000000000 (10 ETH)Same, for EVM (Hood) linked wallets.

Spend caps are still not environment-configured. Every signer — main and linked — starts unlimited; caps, if any, live on each API key (see Spend limits above). The tier-parametrized AGENT_POLICY_PRO_*_CAP_* / AGENT_POLICY_MAX_*_CAP_* env vars and AGENT_POLICY_HOOD_ALLOWED_CONTRACTS (an EVM allowlist of extra contracts, alongside the live Hood curve factory and the USDG quote-asset address, which used to be baked into every policy automatically) are retired along with the tier-default policy that read them; HOOD_CURVE_FACTORY itself is unrelated to this and remains in use for building Hood launches.

POST /api/v1/launch/self/build and POST /api/v1/launch/self/confirm let an agent launch from a wallet it imported itself (see Linked wallets above), instead of the account’s own delegated embedded wallet. Candle builds the transaction, the agent signs and broadcasts it, and Candle afterward verifies and records what actually landed. Candle never signs and never holds a key on this path — a headless launch still has Candle sign, via Privy, on behalf of a wallet it does not control the key to; here Candle never touches a signer key at all.

Current status: both chains work end to end today. Signing a linked wallet needs an app-authenticated call to Privy that an external agent cannot make alone (see “The agent signs” in Flow below), and that call goes through the Candle sign relay, POST /api/v1/agent/wallets/:id/sign. For a Solana-linked wallet, the SDK’s CandleClient.selfLaunch() runs build, sign (through the relay), broadcast, and confirm in one call. For a Hood-linked wallet, selfLaunch() also runs end to end, but the client must configure evmRpcUrl (an EVM JSON-RPC endpoint, separate from solanaRpcUrl) — it throws a clear error naming that option when unset, before any signing. With it set, selfLaunch() assembles the createCurve calldata as an EIP-1559 (type 2) transaction (nonce, gas, and fee data fetched from evmRpcUrl), signs it through the relay, broadcasts it, and waits for its receipt; when the build response carries a feeTransfer leg (a platform fee applies), it does that leg next, at the following nonce, and captures its hash. This still does NOT send a dev buy: createCurve is non-payable, so nothing this one-shot builds ever spends a declared buyAmount. The Hood dev buy remains a separate, out-of-band transaction the agent builds and sends itself, against the curveAddress the build response returns (see “Hood dev buys are a separate, caller-sent transaction” below) — the SDK does not encode a curve buy. To drive the createCurve/fee legs by hand instead of using the one-shot, call buildSelfLaunch()/confirmSelfLaunch() yourself with your own EVM client.

  1. POST /api/v1/launch/self/build — the same body /launch/headless takes (see Request below) plus a required linkedWalletId naming which imported wallet pays. Returns an unsigned transaction and the launch’s identifiers, nothing broadcast:

    { "success": true, "transaction": "<base64>", "mint": "...", "pool": "...", "clientLaunchId": "...", "expiresAt": 1754401800000 }

    On Hood, the transaction is factory calldata rather than a base64 blob, and there is no mint yet (see Build/confirm mechanics below):

    { "success": true, "transaction": { "to": "0x...", "data": "0x..." }, "curveAddress": "0x...", "clientLaunchId": "...", "expiresAt": 1754401800000 }
  2. The agent signs, through the Candle sign relay. The wallet’s Privy signer quorum (the 1-of-1 quorum registered at import time, see Import flow above) 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 transaction 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), which Candle never sees, and calls POST /api/v1/agent/wallets/:id/sign (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 transaction, 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. 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.

  3. The agent broadcasts the transaction itself and reports the resulting signature (a Solana transaction signature, or a Hood transaction hash) to POST /api/v1/launch/self/confirm, with the same clientLaunchId:

    { "clientLaunchId": "my-bot-run-42", "signature": "..." }

    Candle verifies the signature landed on-chain, that it was actually signed by the declared linked wallet, and that it matches the artifacts /build produced, before recording the launch exactly like /launch/headless does (token row, activity, webhooks). The response is the same shape /launch/headless returns (see Response below).

To check a body before step 1, send it to POST /api/v1/launch/self/dry-run (SDK dryRunSelfLaunch()): it gives every payer refusal /build would, and spends and records nothing (see Self-signed dry run).

The SDK exposes buildSelfLaunch() / confirmSelfLaunch() as the two calls above, plus signLinkedTransaction() / broadcastSignedTransaction() as the generic sign-and-broadcast primitives (both chains), plus a one-call selfLaunch() that runs the whole build -> sign -> broadcast -> confirm round trip for both Solana and Hood. On Hood, selfLaunch() requires evmRpcUrl and covers the createCurve transaction plus an optional fee leg only — it never sends a dev buy (see Build/confirm mechanics below); call buildSelfLaunch() / confirmSelfLaunch() directly with your own EVM client if you want to drive the legs by hand instead. Once createCurve is mined, a failure of the fee leg or of the confirm leaves selfLaunch() as a SelfLaunchOutcomeError that keeps the id and every hash (see Hood: a failure after createCurve landed). The SDK itself never signs with a key Candle could have used instead: signLinkedTransaction() computes the agent’s own P-256 authorization signature locally (the key never leaves the process) and only then calls the relay.

  • Never signs. The agent authorizes every transaction itself, with its own P-256 key; Candle’s relay only forwards the already-authorized request to Privy and cannot alter the wallet or the transaction the authorization covers. /build returns an unsigned payload; /confirm only ever verifies what already landed on-chain. (Candle’s server IS a required participant in the signing HTTP call, since Privy also needs app-level auth an external agent cannot supply alone; see “The agent signs” in Flow above. “Never signs” describes who authorizes the transaction, not whether Candle’s infrastructure is involved in the call.)
  • Never holds the key. The linked wallet’s signer was registered at import time from a caller-generated P-256 keypair whose private half never left the caller (see The never-plaintext rule above); nothing about this path changes that. The SDK’s SecretStore (in-memory or an encrypted file) is where an agent keeps that private half between calls, entirely client-side. If the agent loses it, this linked wallet can no longer be signed for: there is no Candle-side recovery, by design, since Candle never held the key to begin with. The only way back is the same one a compromised key would need — revoke the wallet and re-import it with a new signer key (see Quorum rotation below).
  • Never fronts the fee or the dev buy. Both come from the linked wallet, same owner-funded model a headless launch already uses, just with a linked wallet as payer instead of the account’s primary embedded one.

linkedWalletId must resolve, for the calling account, to a linked wallet that is:

  • Active. Not revoked — DELETE /api/v1/agent/wallets/:id tombstones a wallet immediately, and a revoked id is rejected here even though it used to be valid.
  • Spend-capable. Imported (see Import flow above), not attribution-only link-existing: a link-existing row carries no signer and no policy, so there is nothing on it that could ever sign a launch.
  • Chain-matching. A Solana wallet pays for a Solana launch; an EVM wallet pays for a Hood one. Checked at /build (before begin, so a bad id never consumes a clientLaunchId) and again at /confirm (the wallet may have been revoked in the gap between the two calls).

Self-signed launches require Pro or Max tier, the same gate as Linked wallets generally (TIER_REQUIRED otherwise).

Solana. The mint is chosen client-side and known before anything is signed, so /build inserts the full token row immediately, at visibility "hidden" — unconditionally, regardless of the requested visibility — the moment the unsigned transaction is built. Nothing has been signed yet, so a launch that never lands must never appear in a feed. /confirm verifies the reported signature on-chain (the transaction succeeded, the linked wallet is among its signers, the row’s mint is touched by it), promotes the hidden row to its originally-requested visibility, and records activity exactly like /launch/headless does. The ledger row’s own selfLinkedWalletId/selfVisibility/selfBuyAmount fields (stamped alongside mint/pool) carry the payer identity, the originally-requested visibility, and the dev-buy amount across the build/confirm boundary, since /confirm’s own body is just { clientLaunchId, signature } and never re-supplies anything name/symbol/metadata-shaped. All of this is durable Convex state: it survives a restart or a redeploy the same way mint/pool already did. (An in-process cache was the first design and was rejected in review: it would strand an already-broadcast launch across any process boundary — the token row never created, no keeper watching to reconcile it.)

Hood. The base token address is only known from the canonical factory’s own CurveCreated event, discovered when /confirm verifies the broadcast transaction, not before — so /build cannot insert a hidden token row the way Solana’s does; there is no mint yet to key it by. Instead /build stores the predicted CREATE2 curve address as pool and defers the token-row insert entirely to /confirm. Everything /confirm needs, both to verify that curve on-chain and to then insert the token row for the first time (metadata, the platform-deployed splitter clone, the CREATE2 salt, the resolved matrix cell), is stamped onto the same ledger row, alongside the same selfLinkedWalletId/selfVisibility/selfBuyAmount handoff Solana uses. /confirm verifies the launcher (the linked wallet) and every curve parameter through the same on-chain check the web launch flow’s own /hood/register handler makes, then inserts the token row.

A rebuild of the same clientLaunchId reuses the already-pinned image and, unless the resolved streamer changed since the first attempt, the already-deployed splitter clone, rather than paying to redo either on every retry of the same logical launch.

Hood dev buys are a separate, caller-sent transaction. CandleCurveFactory.createCurve is non-payable, so nothing /build assembles ever spends the declared buyAmount. The agent buys, if it buys at all, with its own follow-up transaction, sent and broadcast entirely outside this API, and only ever reports that signature via /confirm’s optional devBuySignature. /confirm binds a reported signature to this specific launch before recording it as this launch’s dev buy: it looks the transaction up and requires both its to to equal this launch’s own curve address and its value to equal the declared buyAmount, exactly. An unrelated transaction, a mismatched amount, or a lookup failure is logged and simply not recorded — the confirm still succeeds — the same tolerance the headless path already gives a dev buy that failed or never landed.

Self-signed launches share the exact same idempotency ledger and daily-launch-cap counter as /launch/headless (see Idempotency above): a clientLaunchId is unique per account across BOTH paths, and the daily cap counts a self-signed attempt the same as any other.

  • Lazy 30-minute expiry. A build that never gets confirmed is not swept by any background job or cron. Instead, every /build call first sweeps the CALLING account’s own stale builds: any submitted, self-signed row whose build is more than 30 minutes old is failed (SELF_BUILD_EXPIRED) and its daily-cap slot refunded, before the new build’s own gates even run. There is no cross-account sweep; an account that never calls /build again simply keeps a stale row until the next UTC day’s counter reset makes it moot regardless.

    On Solana, when the linked wallet broadcasts the launch itself (every linked wallet that is not a TEE wallet), thirty minutes is only when the daily-cap slot is handed back. It is not a time after which a launch that landed can no longer be recorded, and there is no such time. The failed row keeps its build (the mint, the pool, the fee), and /confirm with the transaction’s signature verifies it on chain exactly as it does for a live row, records the launch, and takes the daily-cap slot again, even at the cap. /confirm answers JOB_NOT_FOUND for such a row only when the signature is not found and the chain shows the build can never land. Until then a signature that is not found answers VALIDATION_FAILED, retryable, as for a live row.

  • Same-clientLaunchId retry. Calling /build again with the same clientLaunchId and the same body (including the same linkedWalletId, which is part of what gets hashed for idempotency) replays the same attempt. If it is already confirmed, /build answers IDEMPOTENCY_CONFLICT and /confirm returns the stored success outright, without touching the chain or Convex again. A DIFFERENT body (including a different linkedWalletId) against the same clientLaunchId is IDEMPOTENCY_CONFLICT, same as headless.

    On Solana, when the linked wallet broadcasts the launch itself, one clientLaunchId has one build at a time, and the chain decides what a repeat /build answers. The server never sees the signed transaction on this path, so it reads the build’s mint account and the block height before anything else:

    The earlier build/build answersWhat to do
    Landed: an account exists at its mint409 IDEMPOTENCY_CONFLICT, retryable: false, error.reason: "prior_attempt_on_chain", error.mintRecord it with /confirm and its signature. Do not build again. If the account at that mint is not this launch’s (for example, SOL was sent to the address), /confirm cannot succeed: launch under a new clientLaunchId.
    Can still land200 with the SAME response as the first call: the same transaction, mint, pool, expiresAt and feeBroadcast the transaction you already signed. The sign relay signs a build once, so it refuses these bytes a second time.
    Can still land, and the row was already failed by the 30-minute sweep (or was built before the transaction was stored)409 IDEMPOTENCY_CONFLICT, retryable: true, error.reason: "prior_attempt_in_flight", error.retryAfter (epoch ms)Broadcast and confirm what you signed, or call /build again after retryAfter.
    Proven dead: no account at its mint, read from a node that is caught up, with the chain past the transaction’s last valid block height200 with a NEW transaction, a new mint and a new expiresAtSign and broadcast the new one. The old one can never land.
    The chain could not be readLAUNCH_FAILED, retryable: true, error.reason: "chain_read_failed"Retry.

    A repeat that is refused or returns the same transaction writes nothing: no new mint is drawn and the 30-minute clock does not move, so expiresAt is the original. A transaction usually stops being able to land a little over a minute after it was built; until then a repeat returns the same one. Two overlapping /build calls for one id return one transaction: the other answers LAUNCH_FAILED, retryable, and its retry receives the first one’s transaction. A build the chain proved dead on a row the sweep already failed starts over on the next /build.

    A TEE wallet’s Solana launch keeps its own rule (a rebuild is refused while an earlier signed attempt can still land), and so does a Hood launch from a TEE wallet. A Hood launch from any other linked wallet still rebuilds: a repeat /build returns a fresh { to, data } for a NEW curve address, so do not call it again once you have broadcast.

Linked-scope spend gate: what it actually enforces

Section titled “Linked-scope spend gate: what it actually enforces”

/build checks the calling key’s own spend limits (see Spend limits above) against buyAmount plus the platform fee on it before building anything, but what that check actually bounds differs by chain:

  • Solana: load-bearing. The dev buy is a leg bundled directly into the transaction /build assembles, not a separate transaction the linked wallet’s own Privy policy ever gets to inspect on its own terms — and even if it did, that policy’s own platform-ceiling DENY only ever evaluates the outer transaction’s own shape, never what a program instruction moves internally (live-verified, see “What a cap actually bounds” above). This API-side check at /build is therefore the ONLY enforcement of the key’s cap against a Solana self-signed dev buy.
  • Hood: the declared-vs-sent gap this used to describe is now CLOSED for a linked wallet, not widened. A linked wallet can only ever sign through the build-bound relay (see “The relay signs only API-built transactions” in Agent trading API): every signing call needs both the agent’s own P-256 authorization AND Candle’s app-level secret, which only the relay supplies, so there is no way for the agent to sign anything with this wallet outside it. The standalone Hood dev buy described above (see “Hood dev buys are a separate, caller-sent transaction”) is never built or claim-stamped by /build — createCurve is non-payable, so /build never assembles it at all — which means the relay now refuses it outright with UNRECOGNIZED_TRANSACTION, before any Privy call, regardless of what buyAmount was declared or what the agent tries to send. The self-launch itself (createCurve, plus the optional fee leg) is unaffected, since Candle builds and stamps both of those. Net effect: a linked wallet’s out-of-band Hood dev buy can no longer be SIGNED at all through Candle, which is stricter than the old per-account eth DENY rule ever was, not looser.
  • The delegated (main-wallet) Hood dev buy is a different path, unaffected by any of this. A /launch/headless dev buy on the account’s own embedded wallet signs server-side through Candle’s own owner-signer (see the Request table below, DEV_BUY_MAX_WEI) and is bounded by that per-pair ceiling plus the key’s own server-side spend-limit check — never a Privy policy, since the embedded wallet was never routed through the linked-wallet relay or policy model at all.
  • The one residual declared-vs-sent exposure left anywhere in this picture is an agent buying a launched token from a wallet entirely OUTSIDE Candle’s custody — one Candle never imported, never linked, and holds no signer for. Candle cannot bound that by definition (see “The enforcement boundary” in Agent trading API): it has always been true, and it is not something a linked wallet’s Privy policy, looser or tighter, was ever able to reach.

A linked wallet’s signer is a 1-of-1 Privy key quorum, registered once at import from the caller-generated signerPublicKey (see Import flow above). There is no endpoint to rotate that quorum’s key in place. v1 rotation is: revoke the wallet (DELETE /api/v1/agent/wallets/:id) and re-import it with a new signer key. The wallet address stays the same; only the signer changes, and the fresh import gets the same fixed backstop policy as any other import. This is also the recovery path if the agent simply loses its P-256 key: since Candle never held it, there is nothing to hand back, so revoke-and-reimport is the only way to keep using that wallet address.

Everywhere a wallet address is stamped onto a row (activity, attribution, and this feature’s own payer resolution), the convention is EIP-55 checksummed for EVM addresses and raw base58 for Solana. Older rows may still carry a lowercase EVM address from before this was enforced consistently; code comparing a stored walletAddress must normalize with viem’s getAddress() before an exact match rather than assume the stored casing.

A Hood self-signed launch is up to three separate steps: the createCurve transaction, the fee transfer when the build carried a feeTransfer leg, then /confirm. Once createCurve is mined the curve exists and its gas is spent, whatever happens to the steps after it.

Never call /build, buildSelfLaunch() or selfLaunch() again for that attempt, under the same clientLaunchId or a new one. A Hood rebuild can replace the curve the launch’s row records and create a second curve. Finish the step that is missing instead.

SDK selfLaunch() (in the release after 0.6.0) throws a SelfLaunchOutcomeError when the fee transfer or the confirm fails after a successful createCurve receipt. It is exported from the package root, it is thrown as itself and never inside a CandleOperationError, and operationIds(error) reads its id. It sends nothing a second time and never pays a fee again.

FieldMeaning
clientLaunchIdThe id this call built under, yours or the generated one.
createCurveTxHashThe mined createCurve transaction. It is the signature a confirm takes.
chain, stageAlways "hood" and "executed". executed means createCurve succeeded. It does not say Candle recorded the launch or collected a fee.
step"feeTransfer" or "confirm": the step that failed.
feeOutcomenot-required, not-broadcast, reverted, unknown or landed. See the table below.
feeTxHashEvery fee hash the call learned, including a refused or reverted one. Absent when none is known. A hash alone does not prove payment.
causeThe original error, untouched. A refused confirm is a CandleApiError here, with its own code, status, retryable and reason.

There is no retryable field on the error: what is safe depends on step, feeOutcome and the job’s state. Store the id and both hashes before doing anything else.

stepfeeOutcomeWhat happenedWhat to do
confirmlandedThe fee paid (feeTxHash). The confirm was refused, or its answer was lost.Read GET /api/v1/launch/headless/jobs/:clientLaunchId (SDK getLaunchJob()). The launch may already be recorded. If it is not confirmed and cause allows it, confirm again with the same hashes.
confirmnot-requiredThe build had no fee leg. The confirm was refused, or its answer was lost.The same, with no feeTxHash.
feeTransferunknownThe fee’s broadcast answer was lost, or its receipt could not be read in time. It may have paid.Do not pay again. Look up feeTxHash and the payer’s history. Once that exact fee is verified successful, confirm with it. With no feeTxHash, reconcile the payer’s transactions before any payment decision.
feeTransfernot-broadcastGas estimation or signing failed (no feeTxHash), or the node refused the send (feeTxHash names the refused transaction). The fee did not pay.Pay only the missing fee, then confirm with createCurveTxHash and the successful fee’s hash.
feeTransferrevertedThe fee’s receipt reverted. The fee did not pay.The same.
import { SelfLaunchOutcomeError } from "@candledottv/agent-sdk"
try {
await client.selfLaunch(request)
} catch (error) {
if (!(error instanceof SelfLaunchOutcomeError)) throw error
await store(error.clientLaunchId, error.createCurveTxHash, error.feeTxHash)
// step "confirm": once the job read and error.cause allow it. Nothing is signed or sent.
await client.confirmSelfLaunch({
clientLaunchId: error.clientLaunchId,
signature: error.createCurveTxHash,
...(error.feeTxHash ? { feeTxHash: error.feeTxHash } : {}),
})
}

Do not run that confirm in an unconditional loop: run it once the job state and cause allow it.

  • The job read is Candle’s view, not the chain’s. A failed read, a job that is not confirmed, or a missing fee does not prove that no curve was created. A confirmed job can be confirmed again: it replays the stored success.
  • The fee is still required. When the build stamped a nonzero fee, /confirm answers 402 FEE_LEG_MISSING, retryable: false, for a missing feeTxHash or one that does not verify, and records nothing. A createCurve hash does not waive it, and an unchanged confirm gets the same answer: it needs valid fee evidence first. A build with no fee confirms without feeTxHash.
  • Completing a missing fee uses the original build’s feeTransfer and fee (treasury, quote asset, raw amount) with a current nonce and gas. The error does not carry them: a caller who did not keep the build response must get the original payment details from Candle. Do not request another build to obtain a fee leg, and do not treat a refusal or a timeout as proof the fee did not pay unless feeOutcome says not-broadcast or reverted.
  • Some rows cannot be recorded with /confirm. If the build was replaced, or the row was failed by the 30-minute sweep, /confirm can answer JOB_NOT_FOUND for valid hashes. Keep the hashes and contact Candle. The error keeps the evidence; it does not promise the row can be booked.
  • Before the createCurve receipt nothing changed. An error there is a CandleApiError, a JsonRpcError or a CandleOperationError as before, including a createCurve send whose answer was lost: that case keeps no hash, so check the wallet’s recent transactions.
  • Driving the steps yourself (buildSelfLaunch(), your own EVM client, confirmSelfLaunch()) follows the same rules: after createCurve is mined, keep its hash and never build again.

One new error code: SIGNER_MISMATCH (403) — the confirmed transaction’s signers do not include the linked wallet that built this launch. Everything else self-signed-specific reuses existing codes: VALIDATION_FAILED for a bad, foreign, revoked, wrong-chain, or attribution-only linkedWalletId; TIER_REQUIRED; JOB_NOT_FOUND for a missing or not-yet-built row, or one whose build can never land (see Ledger and counter semantics above for a Solana row the sweep failed); IDEMPOTENCY_CONFLICT, which on a repeat Solana /build carries error.reason prior_attempt_on_chain or prior_attempt_in_flight; DAILY_CAP_REACHED; DEV_BUY_TOO_HIGH for the linked-scope cap above; LAUNCH_FAILED for anything that fails after the transaction has already landed on-chain, always retryable with the same clientLaunchId/signature since a repeat /confirm is safe.

FEE_LEG_MISSING (402, not retryable) on a Hood launch whose linked wallet broadcasts it does not mean “build again”: the curve may already exist. Keep the launch hash, reconcile or complete the original fee, and confirm again with the same signature and that fee’s feeTxHash (see Hood: a failure after createCurve landed).

POST /api/v1/launch/atomic/build and POST /api/v1/launch/atomic/submit launch a token and up to four first buys as one Jito bundle: every transaction lands together, on the same block, or none of them lands at all. This closes the gap every other launch path leaves open, where a snipe bot can land its own buy against a freshly-created pool before the creator’s own follow-up buy gets there. Solana only — there is no Hood/EVM equivalent, since the atomicity this depends on is a Jito block-engine feature specific to Solana. Requires Pro or Max tier (TIER_REQUIRED otherwise) and an agent key with both launch:write and swap:write scopes (SCOPE_MISSING otherwise) — this route builds ordinary agentTrades buy legs alongside the launch itself, so it gates on both scopes the same way the trade rail and self-signed launches do.

  1. POST /api/v1/launch/atomic/build — the same body /launch/headless takes (see Request above), without buyAmount (a nonzero buyAmount here is rejected VALIDATION_FAILED: an atomic launch never bundles a dev buy into the launch transaction itself — give the creator a first buy via firstBuys instead, so it lands as bundle leg 1 against a still-virgin curve, see “The pricing ladder” below), plus payer (who creates the token) and firstBuys (1 to 4 buy legs, Jito’s own 5-transaction bundle cap: 1 launch + up to 4 buys):

    {
    "clientLaunchId": "my-bot-run-77",
    "chain": "solana",
    "quoteAsset": "sol",
    "name": "Trend Coin",
    "symbol": "TREND",
    "imageUrl": "https://example.com/logo.png",
    "payer": { "type": "main" },
    "firstBuys": [
    { "payer": { "type": "main" }, "amountRaw": "50000000" },
    { "payer": { "type": "linked", "linkedWalletId": "wallet-abc123" }, "amountRaw": "25000000" }
    ]
    }

    Each payer is { "type": "main" } (the account’s own embedded wallet, same as a headless launch) or { "type": "linked", "linkedWalletId": "..." } (an imported linked wallet, same payer rules as Self-signed launches above); the launch creator and every buy leg can each use a different payer. The response names every leg in bundle order — index 0 is always the launch, 1..N are the first buys in the order requested — and, for each, who signs it:

    {
    "bundleId": "b7e2b4b0-6b1a-4e0e-9c2a-7b6f0e2f9e21",
    "legs": [
    { "index": 0, "role": "launch", "signer": "server" },
    {
    "index": 1,
    "role": "buy",
    "signer": "server",
    "expectedFill": { "amountOutRaw": "483920112", "minAmountOutRaw": "479080910" }
    },
    {
    "index": 2,
    "role": "buy",
    "signer": "client",
    "unsignedTxBase64": "<base64>",
    "expectedFill": { "amountOutRaw": "238014455", "minAmountOutRaw": "235634310" }
    }
    ],
    "expiresAt": 1755302400000
    }

    A "server" signer (a "main" payer) needs nothing further from the caller — Candle signs it at submit time, the same delegated-wallet path a headless launch uses, and unsignedTxBase64 is omitted entirely. A "client" signer (a "linked" payer) carries unsignedTxBase64 for the agent to sign itself.

  2. The agent signs every "client" leg, in leg order, through the same Candle sign relay Self-signed launches use (POST /api/v1/agent/wallets/:id/sign) — see “The agent signs” under Self-signed launches above for the full mechanism. A bundle with no linked payer at all needs no signing round.

  3. POST /api/v1/launch/atomic/submit — bundleId plus signedTxsBase64, containing exactly the client-signer legs /build named, in leg order (omit every server leg entirely; never pad the array with a placeholder):

    { "bundleId": "b7e2b4b0-6b1a-4e0e-9c2a-7b6f0e2f9e21", "signedTxsBase64": ["<signed base64>"] }

    Candle signs every server leg itself, assembles the fully-signed bundle in the original leg order, and relays it to Jito as one atomic unit. There are three possible outcomes, and unlike every other route in this API, they are not distinguished by a success field:

    • 200, landed:

      {
      "status": "landed",
      "bundleId": "b7e2b4b0-6b1a-4e0e-9c2a-7b6f0e2f9e21",
      "mint": "8t4y...Mint",
      "signatures": ["<launch sig>", "<buy 1 sig>", "<buy 2 sig>"],
      "booking": { "state": "booked", "missing": [] }
      }

      signatures[0] is the launch transaction; signatures[1..] are the buy legs, in the same order /build returned them. Every leg’s own fee, amount, and fill are already exactly what /build recorded — a Jito bundle is all-or-nothing, so nothing here needs the balance-delta verification a broadcast /confirm elsewhere in this API has to do.

      booking (added 2026-10-13) says whether Candle’s own ledger has caught up with the chain. "booked" means the token row, the launch and every buy are recorded. "landed_pending_booking" means the transactions landed and some of that bookkeeping is still owed; missing names it (token, finalization, launch, buy:<clientTradeId>). Nothing needs resending and nothing was lost: Candle completes it without a new transaction, and GET /api/v1/launch/atomic/bundles/{bundleId} reports it. An older client that ignores booking reads the response exactly as before.

    • 502, failed or timed out:

      { "status": "failed", "bundleId": "b7e2b4b0-6b1a-4e0e-9c2a-7b6f0e2f9e21", "retryable": false }
      { "status": "timeout", "bundleId": "b7e2b4b0-6b1a-4e0e-9c2a-7b6f0e2f9e21", "retryable": false }

      retryable is false on every "failed" and on every "timeout" the server returns today. A "timeout" means the resolution window ran out with no definitive answer: the bundle might still land. Treat every "timeout" as unresolved, and do not relaunch under a new clientLaunchId because of one: that is a second token. The bundle is never sent a second time: sending the same bundleId to /submit again answers that bundle’s own outcome once it is known (and the same "timeout" until then). Recovery goes through the launch’s own clientLaunchId: read GET /api/v1/launch/headless/jobs/{clientLaunchId} (it needs launch:read), then send /build again with the same clientLaunchId and body when the job says to. “After a timeout, a failure or a lost response” under SDK below has the steps. A same-id /build after a send can take up to about 95 seconds to answer, because the server resolves the first attempt against the chain before doing anything new.

    • Everything else (400/403/404/409/501/503) is the standard error envelope this API uses everywhere ({ "success": false, "error": { "code", "message", ... } }); see Errors below. A submit refused for its bytes (UNRECOGNIZED_TRANSACTION) signed and sent nothing, and the same bundleId may be submitted again, corrected, until the build expires. A second submit while one is still in progress is 409 IDEMPOTENCY_CONFLICT with retryable: true.

  4. GET /api/v1/launch/atomic/bundles/{bundleId} (launch:read) reads what Candle has stored for one of your bundles. It reads nothing from the chain and resends nothing.

    {
    "success": true,
    "bundle": {
    "bundleId": "b7e2b4b0-6b1a-4e0e-9c2a-7b6f0e2f9e21",
    "clientLaunchId": "launch-001",
    "mint": "8t4y...Mint",
    "operatorState": "landed_pending_booking",
    "signature": "<launch sig>",
    "missingPhases": ["buy:b7e2b4b0-6b1a-4e0e-9c2a-7b6f0e2f9e21:buy:0"],
    "nextAction": "book_missing_phases",
    "buys": [
    {
    "index": 1,
    "clientTradeId": "b7e2b4b0-6b1a-4e0e-9c2a-7b6f0e2f9e21:buy:0",
    "operatorState": "landed_pending_booking",
    "signature": "<buy 1 sig>",
    "rowStatus": "built",
    "errorCode": null,
    "expiredLabelSuperseded": false
    }
    ]
    }
    }

    operatorState is one of:

    StateMeaning
    nullA live build that has not been submitted.
    build_expiredNothing was sent and this build can no longer be submitted. Build again.
    execution_unknownA send was admitted and the chain outcome is not known yet. It may still land.
    landed_pending_bookingThe transactions landed; some bookkeeping is still owed. Nothing to resend.
    bookedLanded and fully recorded.
    revertedThe launch transaction was seen on chain and failed. Build again under the same clientLaunchId.

    Each entry of buys carries its own operatorState. A buy that landed is reported landed_pending_booking even where its trade row still reads failed with TRADE_BUILD_EXPIRED from before 2026-10-13 (expiredLabelSuperseded: true): the row is stale, the buy is on chain. A buy whose own transaction failed on chain is reported reverted: it is final, its errorCode is SLIPPAGE_EXCEEDED or TRADE_REVERTED, nothing is owed for it, and the bundle can still reach booked. The response also carries attemptState, keyPrefix, chainOutcome (with revertedLegs, each failed buy’s signature and code, when there are any), unknownPhases, lastError, lastErrorPhase, lastAttemptAt and recoveryAttempts. An unknown bundleId, or one built by another account, is 404 JOB_NOT_FOUND.

The pricing ladder, and each buy leg’s minimum output

Section titled “The pricing ladder, and each buy leg’s minimum output”

expectedFill.amountOutRaw on each buy leg is the fill computed sequentially against the same curve math the program uses: leg 1 trades against the virgin pool the launch transaction just created, leg 2 against whatever leg 1 left behind, and so on. That ladder is your consent surface. Review it before signing; if a computed fill looks wrong for the amount you asked to spend, do not sign that leg, and rebuild instead.

Each buy leg’s swap instruction carries a real minimum output (since 2026-10-14): expectedFill.minAmountOutRaw, the leg’s ladder fill less 1% (100 bps), rounded down. The two values are decimal strings in the token’s raw units. Inside a bundle that lands whole, every leg fills exactly its ladder amount, so the minimum changes nothing about what you receive. It matters only if a leg is ever seen outside its bundle (a split or replayed bundle): a leg that someone else buys ahead of then fills below its minimum and fails on chain, so you lose one transaction fee rather than the buy. A leg that lands late with only a small buy ahead of it can fill at most 1% below its ladder amount. The minimum is part of the bytes /build returns, so /submit refuses a client leg whose minimum was changed (UNRECOGNIZED_TRANSACTION). A /build that cannot price a leg is refused with VALIDATION_FAILED on firstBuys[i] (“this buy has no computable minimum output”), and no leg is built.

How a buy is recorded depends on how its landing was proven:

  • The block engine reported the whole bundle landed. Each buy is recorded at its ladder amount, which is what it filled.
  • The landing was proven by reading the chain (the block engine’s answer was lost, or a later /submit or same-id /build reconciled the bundle). Each buy that landed is recorded from the tokens its own transaction delivered to the buyer: that amount is what the trade record’s actualOutRaw, the activity row and the trade.executed webhook’s expectedOutRaw report. A buy whose delivered amount cannot be read yet stays landed_pending_booking and is recorded on a later pass.
  • A buy’s own transaction failed on chain. That buy is final, with nothing to resend: its trade fails with SLIPPAGE_EXCEEDED when the program refused the fill below its minimum, or TRADE_REVERTED for any other on-chain failure, and its spend hold is returned. The recovery view shows that buy as reverted, and the rest of the bundle is recorded as usual.

This is also why an atomic launch’s launch transaction never carries a bundled dev buy (a nonzero buyAmount in the build request is rejected outright): the ladder’s math assumes leg 1 trades against a genuinely virgin pool. A dev buy baked into the launch transaction itself would move the price before the ladder’s own leg-0 assumption held, mispricing every fill and minimum after it. Give the creator a first buy through firstBuys instead — it is simply bundle leg 1, priced by the same ladder as every other buy.

Watch your combined first-buy size against the curve’s migration threshold. If your firstBuys legs are large enough to push the curve past graduation on the launch block itself, the curve graduates immediately — but this rail does not arm the automatic DAMM migration a normal follow-up trade would. Keep your combined first buys below graduation, or plan to complete the migration through normal trading on the token afterward.

Candle relays every bundle through a configured Jito block engine. When JITO_BLOCK_ENGINE_URL is unset server-side, /build refuses outright with a 501:

{
"success": false,
"error": { "code": "VALIDATION_FAILED", "message": "Atomic launches require a configured Jito block engine", "retryable": true }
}

JITO_TIP_LAMPORTS (default 100,000 lamports, 0.0001 SOL) sets the tip paid to a Jito tip account per bundle; it rides inside the launch transaction itself, paid by the launch’s own fee payer, not as a separate transaction.

Every buy leg is priced and gated exactly like an ordinary /trade/agent/build buy (same platform fee, same referral discount, same SPEND_LIMIT_EXCEEDED check against the key’s own spend limits — see Spend limits above and the Fee model in Agent trading API), with one addition specific to a bundle: the spend cap is checked twice per leg — once per leg individually (the same literal maxPerTxRaw check a standalone trade gets), and again summed per distinct payer wallet across the whole bundle, since every buy leg here lands from the same wallet(s) in the same atomic event, not as independent trades spread over time. A key’s own txLimit (see Key management in Agent trading API) is checked the same way: once, against the SUMMED value of every buy leg in the bundle (not per leg), using checkKeyTxLimit. (A bundle has only buy legs. On the trade rail a token sell is never refused by txLimit.) A bundle with a main-payer buy and a configured USD cap fails closed if usage or valuation is unavailable (KEY_LIMIT_REACHED, reason: "limit_unavailable", retryable with retryAfter). Every main-payer buy plus its fee is then reserved atomically before owner signing. These trade holds and any TEE debit hold share the key’s window; imported linked buys retain the advisory policy. Settlement records held usage and its marker in one transaction, so it is counted once. An unsigned build failure returns its main buy holds; once owner signing starts, signing acknowledgement loss, Jito errors and ambiguous outcomes keep them. A window reset clears their effect on the new window; a never window does not reset, so its holds stay until the owner raises the limit. No failed cap check reaches Jito.

A built bundle is held in memory for up to 10 minutes (expiresAt on the /build response) — but that figure is an outer bound on server-side storage, not a promise the bundle will still be valid to submit that whole time. Every transaction in the bundle shares one recentBlockhash, fixed at /build time, and Solana’s own blockhash liveness window is roughly 60-90 seconds from the moment it was chosen, regardless of how long the bundle stays queryable server-side. Sign and submit promptly — within well under a minute of building — or the bundle is likely to come back "timeout" even though nothing else was wrong with it. bundleId is single-use regardless of outcome: it is consumed on the very first /submit call, before any verification, so a rejected call (tampered signature bytes, a wrong leg count) can only be corrected by calling /build again, never by retrying /submit with the same id.

Every error code this route can return is one already documented elsewhere in this file or in Agent trading API: TIER_REQUIRED, SCOPE_MISSING, LAUNCH_DISABLED, VALIDATION_FAILED (a nonzero buyAmount, a non-Solana chain, a bad firstBuys/payer shape, or — 501, see Jito configuration above — no configured block engine), IDEMPOTENCY_CONFLICT (409 — a reused clientLaunchId with a different body, or one already confirmed; the same “pick a new clientLaunchId” recovery every other launch route documents, except after a /submit: there, read the job before picking a new id, because a launch you do not yet know landed answers the same 409, see SDK below), DAILY_CAP_REACHED, EXCLUSIVE_NOT_ELIGIBLE, AGENT_WALLET_MISSING, SPEND_LIMIT_EXCEEDED (see above), KEY_LIMIT_REACHED (see above, now also reachable at /build, checked once against the whole bundle’s summed buys; a token sell is never refused with it), TRANSACTION_TOO_LARGE, DELEGATION_REVOKED, INSUFFICIENT_OWNER_BALANCE (402), SPEND_POLICY_DENIED, SWAP_FAILED, LAUNCH_FAILED (the fallback code for an internal failure at either /build or a pre-broadcast owner-signing failure at /submit; always retryable), JOB_NOT_FOUND (an unknown, expired, already-consumed, or foreign bundleId at /submit), and UNRECOGNIZED_TRANSACTION (tampered signature bytes, a missing signature, or a signedTxsBase64 count that does not match the bundle’s own client-leg count). Every code above maps to its usual HTTP status (see the Errors table further down for the full code-to-status map); the two worth calling out here since they are easy to miss are 402 (INSUFFICIENT_OWNER_BALANCE) and 409 (IDEMPOTENCY_CONFLICT). A rejection that never reached Jito (any of the codes above, at either /build or /submit) never consumes or invalidates a still-live clientLaunchId or its ledger state beyond the rejection itself — see the specific ledger notes above for exactly which state survives which failure. bundleId is different: as “bundleId lifetime and blockhash liveness” above documents, every /submit call consumes the bundle immediately, before any verification — so a rejected /submit (tampered bytes, a missing signature, a wrong leg count) still burns the bundleId, even though nothing was ever sent to Jito; only a fresh /build recovers.

The SDK’s CandleClient.launchAtomic() runs the whole build -> sign -> submit sequence in one call: it signs every "client" signer leg, in leg order, through signLinkedTransaction() (requiring privyAppId and a secretStore, same as every other linked-wallet flow — see Self-signed launches above), and skips the signing round entirely for a bundle where every payer is "main". It returns LaunchAtomicResult: the submit outcome plus the clientLaunchId the launch was sent under, yours or the one the SDK generated. "landed", "failed", or "timeout" are all returned values, never thrown, since all three are normal, documented outcomes of submitting a Jito bundle — only a genuine 400/403/404/501/503 throws CandleApiError, same as every other client method. A thrown error carries the clientLaunchId too, and the bundleId once the build has answered: read them with operationIds(error). A lost response or any other failure that is not a CandleApiError is a CandleOperationError with the original as cause. buildAtomicLaunch()/submitAtomicLaunch() are also exposed individually, for a caller that wants to drive the build/sign/submit steps itself (the same split buildTrade()/submit() and buildSelfLaunch()/confirmSelfLaunch() offer); buildAtomicLaunch() returns the clientLaunchId with the bundle, and submitAtomicLaunch() is given only a bundleId and cannot.

import { CandleClient } from "@candledottv/agent-sdk"
const client = new CandleClient({
apiUrl: "https://api.alpha.candle.tv",
apiKey: process.env.CANDLE_API_KEY,
privyAppId: process.env.PRIVY_APP_ID, // only needed if any payer below is "linked"
secretStore, // ditto
})
const result = await client.launchAtomic({
chain: "solana",
quoteAsset: "sol",
name: "Trend Coin",
symbol: "TREND",
imageUrl: "https://example.com/logo.png",
payer: { type: "main" },
firstBuys: [{ payer: { type: "main" }, amountRaw: "50000000" }],
})
if (result.status === "landed") {
console.log(`Launched ${result.mint}, launch signature ${result.signatures[0]}`)
} else {
// "failed" or "timeout": keep result.clientLaunchId and follow the steps below. Never a new id.
console.log(`Launch ${result.clientLaunchId} (bundle ${result.bundleId}) did not land: ${result.status}`)
}

After a timeout, a failure or a lost response. A bundle is sent at most once. The SDK does not resubmit a bundleId; over HTTP, a repeated /submit of one never sends again and answers the bundle’s own outcome, and GET /api/v1/launch/atomic/bundles/{bundleId} reads its state. The launch’s identity is clientLaunchId. Pass your own and store it before the call when you can: an error can carry the id only if the process is still running to catch it. After a "timeout" result, a "failed" result, or an error that carries clientLaunchId:

  1. Read getLaunchJob(clientLaunchId). This needs launch:read on the key. The Write and Withdraw access levels hold it; a key whose scopes were named one by one holds it only if it was named, so add launch:read to any key that runs atomic launches. A scope refusal (SCOPE_MISSING) is not “not found”.
  2. confirmed: the bundle landed. The mint is on the job. Stop.
  3. 404 JOB_NOT_FOUND: the build never began. Call launchAtomic() again with the same id.
  4. failed: the bundle did not land. Call launchAtomic() again with the same id; the server starts a clean attempt under it.
  5. Any other status: the bundle may still land. Wait, then either read the job again or call launchAtomic() again with the same id and the same body. The server resolves the first attempt before doing anything new. It answers 409 IDEMPOTENCY_CONFLICT while the first attempt is unresolved or has just been found landed, so on that 409 read the job again. It builds a fresh bundle only when the first is proven reverted.
  6. Never send a new id while the job is not failed. That is the one path the server cannot refuse, and it is a second token. This includes the 409 whose message says “use a new clientLaunchId to launch again”: read the job first, because that message is also what a caller sees for a launch it does not yet know landed.
  7. One state has no terminal answer today. For an attempt whose blockhash expired with no signature on record, the same-id launchAtomic() keeps answering 409 IDEMPOTENCY_CONFLICT, “This launch was sent; its original attempt must be resolved”, and the job read is still neither confirmed nor failed. Treat it as this state when that remains true well after the bundle’s blockhash must have expired: a Solana blockhash lives about 90 seconds and a same-id rebuild can itself take about 95 seconds, so a few minutes after the submit. Then stop. Do not send a new id on a timer; report the clientLaunchId and bundleId to whoever operates the key.
  8. Treat every "timeout" as unresolved. retryable is false on every "timeout" the server returns today, and it is never permission to relaunch under a new id.

A same-id rebuild can take up to about 95 seconds to answer and a submit up to about 160 seconds, so set no client-side timeout shorter than that on these calls.

A worked curl round trip (main payer throughout, so there is no signing step):

Terminal window
BUILD=$(curl -s -X POST https://api.alpha.candle.tv/api/v1/launch/atomic/build \
-H "x-api-key: $CANDLE_API_KEY" -H "content-type: application/json" \
-d '{
"clientLaunchId": "my-bot-run-atomic-1",
"chain": "solana", "quoteAsset": "sol",
"name": "Trend Coin", "symbol": "TREND", "imageUrl": "https://example.com/logo.png",
"payer": { "type": "main" },
"firstBuys": [{ "payer": { "type": "main" }, "amountRaw": "50000000" }]
}')
BUNDLE_ID=$(echo "$BUILD" | jq -r .bundleId)
curl -s -X POST https://api.alpha.candle.tv/api/v1/launch/atomic/submit \
-H "x-api-key: $CANDLE_API_KEY" -H "content-type: application/json" \
-d "{\"bundleId\": \"$BUNDLE_ID\", \"signedTxsBase64\": []}"
# -> { "status": "landed", "bundleId": "...", "mint": "...", "signatures": [...] }

This request launches a real token and spends real funds. The agent console’s Skills tab carries it trimmed to the required fields, filled with a new key while its secret is on screen and otherwise read from $CANDLE_API_KEY. To check a key is alive without spending anything, use the read-only GET /api/v1/agent/wallets instead.

Terminal window
curl -X POST "$API_URL/api/v1/launch/headless" \
-H "x-api-key: $AGENT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"clientLaunchId": "my-bot-run-42",
"chain": "solana",
"quoteAsset": "sol",
"mode": "open",
"stakerAllocationBps": 50,
"buyAmount": 1000000,
"name": "Trend Coin",
"symbol": "TREND",
"imageUrl": "https://example.com/logo.png",
"description": "Launched by the trend bot",
"socials": { "twitter": "https://x.com/...", "website": "https://..." },
"visibility": "production"
}'
Terminal window
curl -X POST "$API_URL/api/v1/launch/headless" \
-H "x-api-key: $AGENT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"clientLaunchId": "my-bot-run-43",
"chain": "hood",
"quoteAsset": "eth",
"dexVersion": "v4",
"mode": "open",
"buyAmount": "1000000000000000",
"name": "Trend Coin",
"symbol": "TREND",
"imageUrl": "https://example.com/logo.png",
"visibility": "production"
}'
FieldRequiredNotes
clientLaunchIdyesIdempotency key, unique per account. 1-128 chars. See Idempotency below.
chainnosolana (default) or hood.
quoteAssetnoDefaults to the chain’s default (sol on Solana, eth on Hood). Solana: sol, usdc, cndl. Hood: eth, usdg.
modenoopen (default) or exclusive. v2 changed this default from exclusive to open; pass mode: "exclusive" explicitly if that is what you want. Exclusive requires Believer NFT eligibility, see below. exp-a, exp-b and exp-c are the experimental fast curves, and exp-custom is a curve with your own virtual quote and threshold (see Custom curves below): Solana only, against sol or cndl; exp-custom only where the API has EXP_CUSTOM_CURVES on. Elsewhere exp-custom is refused as an unknown mode.
curveexp-custom only{ "virtualQuote": "12", "threshold": "40" }: V and G in WHOLE units of the quote asset, as decimal strings. Required with exp-custom and refused with any other mode (VALIDATION_FAILED, field: "curve"). See Custom curves below for the limits.
stakerAllocationBpsnoInteger, 50-1000 (0.5%-10%). Defaults to 50, or 500 for the exp- modes, whose curve is derived from it.
dexVersionHood onlyv3 or v4, required for chain: "hood". Rejected (VALIDATION_FAILED) if set for Solana.
buyAmountnoInitial dev buy, in the QUOTE asset’s raw units: whole units times 10^decimals, so 0.1 SOL is 100000000 and 25 USDC is 25000000 (GET /launch/quote-pairs gives each asset’s decimals). Number or decimal string; Hood wei must be a string (it exceeds Number.MAX_SAFE_INTEGER); Solana amounts must fit a safe number. Defaults to 0. Per-pair support and ceilings (the quote-pairs matrix carries headlessDevBuy per pair): Solana sol bundles atomically via a wSOL wrap, capped DEV_BUY_MAX_LAMPORTS (default 0.5 SOL); Solana usdc and cndl bundle atomically drawing from the owner wallet’s existing token balance, capped DEV_BUY_MAX_USDC_RAW (default 500 USDC) / DEV_BUY_MAX_CNDL_RAW (default 2,000,000 CNDL); Hood eth runs as a best-effort follow-up transaction, capped DEV_BUY_MAX_WEI (default 0.02 ETH); Hood usdg has no dev buy. Unsupported pair: DEV_BUY_UNSUPPORTED_FOR_PAIR; above a cap: DEV_BUY_TOO_HIGH. An SPL-funded buy needs the owner wallet to already hold the quote asset; a shortfall fails INSUFFICIENT_OWNER_BALANCE at simulation.
name, symbolyesUTF-8 byte limits: name 32, symbol 10 (Metaplex/on-chain metadata limits). Must not contain characters that would break the on-chain metadata JSON, such as " (VALIDATION_FAILED, or INVALID_SYMBOL for the symbol).
imageUrlyeshttps URL. Server fetches, validates (JPEG/PNG/GIF/WebP, <10MB, roughly SQUARE: aspect ratio at most 1.5:1, else IMAGE_WRONG_SHAPE), re-hosts. The image renders as a small circle/square avatar everywhere, so share cards, OG images and banners do not belong here: pass those as bannerUrl instead. Must resolve to a public address (no private/loopback hosts) and must not redirect. Agents without their own image host should upload the bytes first via the hosted image pipeline below and use the returned URL.
bannerUrlnohttps URL to WIDE artwork for the token page’s banner strip, a full-width band above the token’s details. Same fetch, type, size and SSRF rules as imageUrl, and re-hosted the same way, but the opposite shape gate: it must be wider than 1.5:1 (e.g. 1200x630), else BANNER_WRONG_SHAPE. The two gates partition every image, so any given file is valid for exactly one of the two fields. null reads as omitted. Omit it and the strip falls back to imageUrl, which is what every launch got before this field existed.
descriptionnoSame JSON-safety rule as name (VALIDATION_FAILED).
socialsnoObject of strings; only twitter, telegram, website, discord are used. Each value must be an http(s) URL (VALIDATION_FAILED).
visibilitynoproduction (default), test, local, hidden. Test keys are restricted, see above.
asyncnoBoolean, default false. When true the server returns 202 after the pre-flight gates and finishes in the background; see Async launches and jobs. Excluded from the idempotency hash, so async and blocking retries of the same clientLaunchId never conflict.

The headless route and its dry run always pay from the embedded wallet. A body that carries payer, wallet, linkedWalletId or firstBuys is refused with 400 VALIDATION_FAILED and field naming the key, rather than launched from the embedded wallet: to launch from a linked wallet use the self-signed route (POST /api/v1/launch/self/build with linkedWalletId), and for first buys use POST /api/v1/launch/atomic. A null value reads as absent. Other extra fields are still ignored.

Custom curves: choose V and G (switched off)

Section titled “Custom curves: choose V and G (switched off)”

mode: "exp-custom" launches a Solana constant-product curve with a virtual quote (V) and a threshold (G) that you choose. V is the quote the curve starts with, which sets its starting price and how steep it is. G is how much quote it raises before it graduates. Name both in curve, in whole units of the quote asset as decimal strings: "12" is 12 SOL, not 12 lamports. The presets exp-a, exp-b and exp-c are this same curve at V = 4, 6 and 8 SOL, each with G = 5 SOL.

The headless launch and its dry run, the self-signed /launch/self/dry-run and /launch/self/build, and atomic /launch/atomic/build take it, for sol and cndl pairs only. The switch is EXP_CUSTOM_CURVES. Where it is off, exp-custom is refused as an unknown mode (VALIDATION_FAILED, field: "mode") before anything is recorded. A token already launched on a custom curve keeps trading and graduating whatever the switch says.

A curve is accepted when all four of these hold:

RuleSOL pairCNDL pairRefused with
Outer limitsV from 1 to 100 SOL, G from 1 to 250 SOLV from 66,000 to 6,600,000 CNDL, G from 66,000 to 16,500,000 CNDLCURVE_OUT_OF_RANGE, reason virtual_quote_out_of_range or threshold_out_of_range
Price ceiling(V + 20G)^2 < V x X, in raw units, where X is the supply less the staker allocation at this request’s stakerAllocationBpsthe sameCURVE_OUT_OF_RANGE, reason: "price_ceiling", with maxThresholdRaw: the largest G, raw, that fits this V at this allocation
Precisionat most 9 decimalsat most 6 decimalsVALIDATION_FAILED, reason: "curve_precision" ("curve_format" for anything that is not a plain decimal), field curve.virtualQuote or curve.threshold. Never rounded
Dev buybuyAmount at most 10% of G, and never above the ordinary ceilingthe sameDEV_BUY_TOO_HIGH, reason: "curve_dev_buy_cap"

The price ceiling keeps every price the curve can reach, up to 20 x G, in the range where the curve program and Candle’s quotes agree exactly. On SOL it binds only for a small V. At the default 500 bps, V = 1 SOL allows G up to about 48.68 SOL, and V = 56 SOL allows up to about 361.89 SOL, so there the 250 SOL outer limit applies. On CNDL it always binds first, far below the outer limit: V = 66,000 CNDL allows G up to about 392,616 CNDL, and V = 6,600,000 CNDL up to about 3,629,166 CNDL. No other ratio between V and G is imposed. A small V against a large G sells most of the supply cheaply. That is your choice, and the terms show it before you sign.

Dry-run first. The dry run checks the curve and spends nothing. It returns resolved.curve (virtualQuoteRaw, thresholdRaw) as validated, and curveTerms. Every launch response of an exp- mode carries curveTerms too:

FieldMeaning
symbol, virtualQuoteRaw, thresholdRaw, raiseThe quote asset, V and G raw, and G in whole units.
startFdv, bondingFdvMarket cap at the starting price and at graduation, in the quote asset, on the whole supply.
supplySoldPctShare of the supply sold by graduation, percent.
firstBuyRaw, firstBuySupplyPctHow much of the supply the first 1 SOL (or 66,000 CNDL) buys, percent.

These are display figures from the closed form, before fees. curve is part of the idempotency hash in raw units, so a resend of "5" as "5.0" is the same request. A different V or G under the same clientLaunchId is IDEMPOTENCY_CONFLICT. Through MCP, candle_launch_token and candle_launch_and_seed take the same mode and curve.

Streaming was retired on 2026-09-14, so createdDuringStream is always false and the launching dev fills the streamer slot.

The dev buy, the network fee, and every other cost of the launch are all paid from the key owner’s own embedded wallet (Solana or Hood EVM, whichever chain the launch targets), signed via Privy, never from a platform wallet. The per-launch ceiling no longer bounds platform exposure; it bounds the blast radius of a stolen or misused API key against the owner’s own funds. dailyLaunchCap x ceiling is still the right way to think about worst-case exposure, it is just the owner’s exposure now, not Candle’s — every launch attempt (confirmed or still in-flight) consumes one daily-cap slot regardless of who pays for it.

A request against a chain the account has no embedded wallet for fails AGENT_WALLET_NOT_EMBEDDED before anything is built; an owner wallet that cannot cover fee + rent + dev buy fails INSUFFICIENT_OWNER_BALANCE at simulation, before broadcast. Both are retryable once fixed (get an embedded wallet and delegate it; fund the wallet). See Agent access above and Errors below.

Hood launches additionally require the calling account to have a linked EVM wallet; without one the launch fails (LAUNCH_FAILED) before it reaches the chain. That wallet is always the Privy-verified linked wallet for the key owner’s account, resolved fresh via Privy, never the (unverified, user-editable) evmWalletAddress profile field — the same rule applies to the wallet used for Hood activity attribution in POST /activity/report.

POST /api/v1/uploads/agent-image (agent key, launch:write scope; the per-key uploads/min ceiling is the plan’s: 10 on Free, 30 on Pro, 60 on Max) is the Candle-hosted image pipeline: send the image bytes, get back a public imageUrl ready for the launch body, hosted through the same pipeline the web launcher uses. Accepts either multipart form data with an image field, or the raw bytes with an image content-type header:

Terminal window
curl -X POST "$API_URL/api/v1/uploads/agent-image" \
-H "x-api-key: $AGENT_API_KEY" \
-H "Content-Type: image/png" \
--data-binary @logo.png
{ "success": true, "imageUrl": "https://..." }

Same limits as every upload path (JPEG/PNG/GIF/WebP, under 10MB); violations fail UPLOAD_INVALID (400) with the reason. This removes the one previously unavoidable piece of integrator infrastructure: a public, redirect-free https image host.

The Candle CLI calls this route for an image file on your machine, from @candledottv/cli 0.11.24, so an agent with logo.png beside it needs no curl:

Terminal window
candle launch --name Demo --symbol DEMO --image ./logo.png --wallet agent # upload, then launch
candle image upload ./logo.png # upload only; prints the URL
  • candle launch --image <path> uploads the file and launches with the URL that comes back. It is the TEE-wallet launch (Which launch path), and --image-url still takes an https:// URL.
  • candle image upload <path> prints the hosted URL and does nothing else. Pass that URL as imageUrl to any launch route here, to the SDK, or to a connector tool.
  • The local connector calls it too, from @candledottv/mcp 0.10.12, where its operator set CANDLE_IMAGE_DIR: candle_launch_token and candle_launch_and_seed then accept imagePath, an absolute path to a file inside that directory, upload it and launch with the URL that comes back (MCP server). A dry run uploads too. The hosted connector cannot read a file and takes imageUrl only.

Both read the image type from the file’s first bytes and refuse anything that is not one of the four types or is over 10MB before a request is made. Nothing else about a launch changes: imageUrl is still an https URL on the wire, and bannerUrl is always a URL, because this route crops to a square and cannot host a banner.

An upload is public and permanent. The file is pinned to public storage and its URL cannot be withdrawn, whether or not a launch follows; candle launch --image uploads before its confirm prompt. A square image keeps its original bytes, metadata such as EXIF and GPS data included. Uploading spends no funds and does not count against the daily launch cap; only the uploads-per-minute limit above applies.

clientLaunchId is required and must be unique per account. The server keeps a ledger row per (account, clientLaunchId) and treats the pair as the source of truth for “did this launch already happen”:

  • Same id, same body, already confirmed: replays the original success response. No new token is minted.
  • Same id, different body: IDEMPOTENCY_CONFLICT (409), not retryable. Pick a new clientLaunchId.
  • Same id, still in flight: IDEMPOTENCY_CONFLICT (409), retryable. Retry shortly with the same id.
  • Same id, previously failed: retried as a fresh attempt.

Never blind-POST a retry with a new id. On a 500/timeout, retry with the same clientLaunchId; the server resolves whether the original attempt landed before doing anything new. This matters most for a confirmation timeout: the broadcast transaction may still land even though the server gave up watching it. In that case the ledger row is left submitted (not failed), on purpose, so:

  • A same-id retry gets the in-flight 409 above, over and over, until the row is resolved. It does not re-execute and risk a double mint.
  • Verify the mint on-chain (or poll nextBuy.marketStateUrl, see Market state below) before starting over under a new clientLaunchId.
  • A row stuck submitted also still counts against the account’s daily launch cap. Operators may need to reconcile it directly (confirm it with the mint that actually landed, or mark it failed) since there is no self-serve endpoint to force a resolution today.

A row that failed for a definitive reason (validation, a decoded on-chain revert, insufficient balance) is recorded failed and does not block a same-id retry; the next attempt with that id starts over.

POST /api/v1/launch/headless/dry-run runs every check (quote asset resolution, image reachability, exclusive eligibility) with the same request body, but performs no Convex writes, consumes no daily-cap slot, and executes nothing on-chain. Requires the same launch:write scope.

The headless dry run is for the headless route only. Its body must be sent without payer, wallet, linkedWalletId and firstBuys (they are refused, see the request-fields note). The launchWallet it returns is the embedded wallet, and it checks none of the payer rules. To check a launch from a linked or TEE wallet, use the self-signed dry run below. The atomic route has no dry run.

checks.exclusiveEligible is present only when mode is exclusive (or test-exclusive), the modes that run the Believer check; an ineligible account gets EXCLUSIVE_NOT_ELIGIBLE (403) instead of a response, so when present it is true. In any other mode the field is absent: nothing was checked, and an open-mode dry run says nothing about whether this account may launch in exclusive mode.

On Solana, the dry-run additionally assembles the REAL launch transaction — the same builder (buildCreateTokenAndPoolTxInApp), the same dev-buy legs when buyAmount is non-zero, and the same agent-tier fee leg when one would apply — and size-checks it, so an oversized name+symbol combined with a bundled dev buy is caught here instead of only surfacing when the real (fee-spending) launch is attempted. A fitting Solana body’s response carries a size block:

{
"success": true, "dryRun": true,
"launchWallet": "...",
"resolved": { "chain": "solana", "quoteAsset": "sol", "mode": "open", "stakerAllocationBps": 50, "dexVersion": null, "visibility": "production", "buyAmount": "500000000" },
"checks": { "image": "ok" },
"matrixVersion": 1,
"size": { "txBytes": 1218, "limit": 1232, "maxNameBytes": 28, "fits": true }
}

launchWallet is the address the real launch will pay from, so an agent can confirm it holds funds first (a dry run never checks balance). checks.banner is "ok" when a bannerUrl was sent and checked, and absent otherwise.

size fieldNotes
txBytesThe assembled transaction’s byte count for this exact request (name, symbol, dev buy, and fee leg included).
limitSolana’s raw packet limit, 1232 bytes.
maxNameBytesThe largest name (UTF-8 bytes) that would still fit THIS exact request (same symbol, dev buy, and fee leg) — name bytes map 1:1 to transaction bytes, so this is exact, not an estimate. Use it to size a name before retrying.
fitsAlways true when present: a request that would NOT fit fails the dry-run outright (TRANSACTION_TOO_LARGE, see Errors below) rather than reporting success with fits: false.

An oversized Solana body fails the dry-run itself with TRANSACTION_TOO_LARGE (see Errors below), including maxNameBytes, instead of reporting a false success. Hood is unaffected: Hood never bundles its dev buy into the launch transaction (a separate, best-effort follow-up transaction), so a Hood dry-run’s response has no size block at all.

POST /api/v1/launch/self/dry-run (SDK dryRunSelfLaunch()) takes the same body as /launch/self/build, with the same launch:write scope and plan, and runs the same checks with the same code: validation, the plan’s self-launch capability, the test-key and test-curve rules, the payer (the wallet exists on this account, is on the launch chain, can sign, and for a TEE wallet allowLaunch and the bound key), buyAmount 0 for a TEE wallet unless TEE_LAUNCH_BUYS_ENABLED is on, a Solana TEE key’s per-transaction SOL cap, the Believer check for exclusive, and the key’s spend limit on the dev buy. Then it probes the image and, on Solana, sizes the transaction, as the headless dry run does.

A refusal answers with the same status, code, field and message /build gives for that body. It builds nothing and writes nothing: no ledger row, no day-cap slot, no spend-window reservation, no sign claim, no upload, and the clientLaunchId stays unused, so the same body can go straight to /build.

The response has the headless dry run’s shape. launchWallet is the linked or TEE wallet /build would pay from, and launchWalletId is its id:

{
"success": true, "dryRun": true,
"launchWallet": "...", "launchWalletId": "...",
"resolved": { "chain": "solana", "quoteAsset": "sol", "mode": "open", "stakerAllocationBps": 50, "dexVersion": null, "visibility": "production", "buyAmount": "0" },
"checks": { "image": "ok" },
"matrixVersion": 1,
"size": { "txBytes": 1104, "limit": 1232, "maxNameBytes": 32, "fits": true }
}

It does not check the wallet’s balance, the same as the headless dry run. Three other things /build can still refuse are not checked either: the daily launch cap (DAILY_CAP_REACHED), a clientLaunchId already used with a different body or payer (IDEMPOTENCY_CONFLICT), and, for a Solana TEE wallet, the key’s USD spend window, which is only known by reserving it at /build.

Two additions make the launch a pollable state machine instead of one blocking request:

GET /api/v1/launch/headless/jobs/:clientLaunchId (requires the launch:read scope, which this endpoint is the first to enforce) reads one attempt from the idempotency ledger:

{
"success": true,
"job": {
"clientLaunchId": "my-bot-run-42",
"chain": "solana",
"status": "confirmed",
"mint": "...",
"pool": "...", "signature": "...",
"createdAt": 1754400000000,
"updatedAt": 1754400060000
}
}

status is submitted, confirming, confirmed, or failed (with errorCode). An unknown id under this account is JOB_NOT_FOUND (404). This is the read side of the in-flight 409: a row stuck submitted after a confirmation timeout is visible here while you verify the mint on-chain. A Hood TEE self-launch’s job also carries job.operation: the sequenced operation and its fee legs. A headless Hood launch that attempted a dev buy carries job.devBuy.outcome, job.devBuy.pendingSignature and job.devBuy.feeOutcome, with the meanings in the response table above; this read is where a dev buy that was pending when the launch returned is later seen as landed or reverted. Reading the job never resends anything.

POST /api/v1/launch/headless with "async": true runs every pre-flight gate (validation, idempotency begin, daily cap, exclusive eligibility, embedded-wallet check) synchronously, then returns immediately and executes in the background:

{ "success": true, "accepted": true, "clientLaunchId": "my-bot-run-42", "status": "submitted", "jobUrl": "/api/v1/launch/headless/jobs/my-bot-run-42" }

HTTP 202. Poll jobUrl until confirmed or failed. Replay semantics are unchanged: a same-id retry of a confirmed launch returns the stored success (200, blocking shape), and an in-flight row returns the retryable 409 whether or not the original request was async.

{
"success": true,
"chain": "solana",
"mint": "...",
"pool": "...",
"signature": "...",
"quoteAsset": "sol",
"mode": "open",
"stakerAllocationBps": 50,
"matrixVersion": 1,
"links": {
"candle": "https://candle.tv/token/...",
"explorer": "https://solscan.io/tx/..."
},
"nextBuy": {
"market": "...",
"quoteAsset": "sol", "marketStateUrl": "/api/v1/markets/solana/..."
},
"devBuy": { "fee": { "bps": 100, "feeRaw": "...", "treasury": "..." } }
}
FieldNotes
poolnull when the launch has no pool address yet (e.g. an in-flight replay).
matrixVersionThe launch economics matrix version this launch resolved against. Echoed so integrators can tell which curve/fee terms applied.
links.explorerAlways present on Solana (Solscan). On Hood, present only when HOOD_EXPLORER_URL is configured server-side; omitted otherwise.
nextBuy.marketThe pool address, or the mint when there is no pool yet. Where an agent places its next buy.
nextBuy.marketStateUrlRelative path; prefix with your API_URL. See Market state below.
devBuy.signatureHood only: the separate dev-buy transaction, once it has a success receipt. A Solana dev buy rides the launch signature. On Hood the dev buy is best-effort (see Hood notes); its absence does not mean the launch failed, and it does not mean no buy is outstanding: read devBuy.outcome.
devBuy.outcomeHood headless launches that attempted a dev buy: landed, reverted, pending, lapsed or unknown. pending (sent, no receipt yet) and unknown (a send was attempted and its hash was not returned) mean the buy may still land: do not place a replacement buy on that reading. lapsed means the wallet’s nonce was used and no receipt was found in 24 hours, which is not proof either way. Only reverted means no buy is outstanding. Candle keeps reading the chain for up to 24 hours after the send and updates the job read. Absent on a launch that attempted no dev buy. An absent outcome proves nothing for a launch made before this field existed, or read from a job that predates it: such a launch may have a buy in flight.
devBuy.pendingSignatureThe dev buy’s transaction hash while outcome is pending, lapsed or reverted. Look it up on chain, or read the job again. devBuy.signature stays the landed hash only.
devBuy.feeOutcomeThe platform fee leg’s state, when a fee was planned on the dev buy: not_sent (the buy had not landed when the request ended, so no fee was sent), forgone (the buy landed late and Candle did not collect the fee; nothing is owed), landed, pending, unknown, reverted, refused or lapsed.
devBuy.fee{ bps, feeRaw, treasury }: the platform fee charged on the dev buy, when one applied. devBuy is omitted when there is no dev-buy signature, no outcome and no fee.

Every error response is a structured envelope:

{ "success": false, "error": { "code": "DAILY_CAP_REACHED", "message": "...", "field": "...", "retryable": true } }

Branch on error.code, never on message. field is present only for field-level validation errors. retryable is a hint, not a guarantee. TRANSACTION_TOO_LARGE additionally carries txBytes, overBy, and maxNameBytes (see its row below) — both at /dry-run and on the real launch path, so a caller that skipped the dry-run still learns the name budget straight from a failed launch’s error response.

A buyAmount that is not a non-negative whole number (number or decimal string) is VALIDATION_FAILED with field: "buyAmount". It is a format error, not an amount error: buyAmount is in the quote asset’s base units, so 0.1 fails and 0.1 SOL is 100000000 (0.1 x 10^9 lamports). GET /api/v1/launch/quote-pairs gives each asset’s decimals.

CodeStatusMeaningTypically retryable
VALIDATION_FAILED400Generic field validation failure (see field).no
INVALID_NAME_BYTES400name exceeds 32 UTF-8 bytes.no
INVALID_SYMBOL400symbol exceeds 10 UTF-8 bytes.no
IMAGE_TOO_LARGE400imageUrl resolves to a file over 10MB.no
IMAGE_UNREACHABLE400imageUrl did not resolve or respond in time.no
IMAGE_REDIRECT_NOT_ALLOWED400imageUrl issued a redirect; not followed.no
BANNER_UNREACHABLE400bannerUrl did not resolve or respond in time.no
BANNER_WRONG_SHAPE400bannerUrl is not wider than 1.5:1 (see the bannerUrl row above).no
DEV_BUY_UNSUPPORTED_FOR_PAIR400Non-zero buyAmount against a quote asset that does not support a dev buy.no
DEV_BUY_TOO_LOW400Retained for older servers, which answered a malformed buyAmount with it. Current servers answer VALIDATION_FAILED with field: "buyAmount" instead (see the paragraph above this table). Treat either as a format error: fix the value, do not retry it unchanged.no
DEV_BUY_TOO_HIGH400buyAmount exceeds the per-launch dev-buy ceiling (DEV_BUY_MAX_LAMPORTS, DEV_BUY_MAX_USDC_RAW, DEV_BUY_MAX_CNDL_RAW or DEV_BUY_MAX_WEI; defaults 0.5 SOL, 500 USDC, 2,000,000 CNDL, 0.02 ETH; see the buyAmount row above). Solana also rejects here if buyAmount exceeds Number.MAX_SAFE_INTEGER, though the ceiling is far lower and dominates in practice.no
QUOTE_PAIR_UNKNOWN400quoteAsset is not a known pair for chain.no
CURVE_OUT_OF_RANGE400exp-custom only: curve is outside the limits (see Custom curves above). reason names the rule: virtual_quote_out_of_range, threshold_out_of_range or price_ceiling. For price_ceiling, maxThresholdRaw is the largest G that fits this V.no
EXCLUSIVE_NOT_ELIGIBLE403mode: "exclusive" requested but the account is not Believer-NFT eligible.no
UNAUTHORIZED401Missing, malformed, unknown, or revoked API key.no
SCOPE_MISSING403The key lacks the scope this route requires.no
EMBEDDED_WALLET_NOT_PERMITTED403The key was issued with embeddedWallet: "denied", and a headless launch signs with the embedded wallet. Returned by headless, dry-run and atomic launches before anything is recorded.no, change the key’s permission
SIGNER_MISMATCH403Self-signed launches: the confirmed transaction’s signers do not include the linked wallet that built it.no
FEE_LEG_MISSING402Self-signed launches: the broadcast transaction lacks the required platform fee transfer, or it did not verify.no. Solana: /build again. Hood, caller-broadcast: never rebuild, the curve may exist; reconcile or complete the original fee, then confirm again with the same signature and that feeTxHash
AGENT_WALLET_NOT_EMBEDDED403The account has no Privy embedded wallet for the requested chain — a self-connected wallet (Phantom, MetaMask) can never be delegated. Not retryable with the same key; the account needs to sign up via Google, X, or email to get one, then delegate it.no
AGENT_WALLET_NOT_DELEGATED403An embedded wallet exists for the requested chain but agent access was never approved for it. Returned by POST /api/v1/agent/keys at issuance, and can also surface at launch time if the owner had delegated at issuance and later un-delegated without Privy reporting it as a revocation.yes, after delegating
DELEGATION_REVOKED403Delegation existed and was approved, but Privy rejects the signing/sending call now — revoked mid-flight, or any time between key issuance and this launch. The key itself stays valid; only delegation needs to be re-approved.yes, after re-delegating
INSUFFICIENT_OWNER_BALANCE402The owner’s own wallet — the only wallet that ever pays for a headless launch — cannot cover fee + rent + dev buy. Caught at simulation, before broadcast.yes, once funded
TRANSACTION_TOO_LARGE400The assembled launch transaction exceeds Solana’s 1232-byte packet limit; realistically a long name+symbol combined with a bundled dev buy. Caught at build time, before signing (also caught early by /dry-run, see above). The response’s error.maxNameBytes names the largest name (UTF-8 bytes) that would fit THIS exact request (same symbol, dev buy, and fee leg); error.txBytes/error.overBy give the raw byte count and how far over the limit it landed. Shorten the name to maxNameBytes or fewer bytes, shorten the ticker, or launch without the dev buy.no, change the request
MARKET_NOT_FOUND404No market for that chain/mint pair (market-state endpoint only).no
IDEMPOTENCY_CONFLICT409Same clientLaunchId reused with a different body (not retryable), or the launch is still in flight (retryable).sometimes, see Idempotency
RATE_LIMITED429The key’s effective per-minute rate limit was exceeded. That ceiling is max(key row, plan floor): by default 30/min on Free, 300 on Pro, 600 on Max (see Caps and plans above).yes, after the window
DAILY_CAP_REACHED429Account’s daily launch cap reached.yes, next day
LAUNCH_FAILED500Build, broadcast, or confirmation failed (includes confirmation timeout, see Idempotency).yes, same clientLaunchId
LAUNCH_DISABLED503HEADLESS_LAUNCH_ENABLED is not "true" server-side.no
HOOD_LAUNCH_UNAVAILABLE503Hood only. The launch cannot run because Candle’s own Hood configuration is incomplete (a signer key, RPC, or contract address is missing). Nothing about the request is wrong, and nothing was broadcast. Also returned by /dry-run when an exclusive Hood launch’s Believer check cannot run. The attempt is recorded as failed, so the same clientLaunchId starts a clean attempt.yes, later, same clientLaunchId
TIER_REQUIRED403The account’s plan does not include this: importing or linking a wallet, a self-signed launch and an atomic launch need Pro or Max. The capabilities in GET /api/v1/agent/plans list each gate.no
WALLET_LIMIT_REACHED400The account already has the maximum active linked wallets for its tier; revoke one first.yes, after revoking one
KEY_EXPIRED401The key’s own expiresAt has passed. Checked after the secret is verified, so a bare prefix cannot probe expiry. Expiration is fixed at creation: recovery is always a new key.no
KEY_LIMIT_REACHED403The key’s own txLimit (windowed USD trading volume) would be exceeded by this request. Returned for buys and for base-pair trades on either side; a token sell is never refused with it. Carries resetsAt (epoch ms, or null for a lifetime cap).yes when resetsAt is non-null, after the window rolls
SPEND_LIMIT_EXCEEDED400A buy’s amountRaw + feeRaw exceeds the calling key’s spend cap for that asset. Carries spendLimit.limitSource (always key). See Spend limits above.no, as the same amount
TEST_ENVIRONMENT_FORBIDDEN403A test-environment key called a rail that has no non-production mode (the trade rail, atomic launches).no
BUILD_TIMEOUT504A build step exceeded its deadline (quote, simulation, or bundle assembly).yes
UNRECOGNIZED_TRANSACTION403The sign relay found no matching build-time claim for the submitted transaction: hand-rolled, already signed once, or the claim expired.no, rebuild first
SPEND_POLICY_DENIED403The owner wallet’s own Privy policy rejected the transaction.no
AGENT_WALLET_MISSING400Main payer: the account has no embedded wallet for that chain.no
SWAP_FAILED500An unclassified failure executing or recording a swap leg. Message states whether the transaction may already be on-chain.sometimes, see the message
JOB_NOT_FOUND404The clientLaunchId/clientTradeId does not resolve to a live row: never built, or swept after expiry.no, rebuild
KEY_ISSUANCE_FAILED503A dependency needed to issue the key was unreachable (Convex, Privy, or the on-chain Believer check).yes
IMAGE_WRONG_SHAPE400The uploaded image is not square, or violates the size bounds.no, change the file
UPLOAD_INVALID400The upload body was malformed or the content type is unsupported.no
WEBHOOK_LIMIT_REACHED400The account already has the maximum registered webhook endpoints.yes, after deleting one
WALLET_ALREADY_LINKED409This address already has an active linked-wallet row, on this account or another one.no
LOOSEN_REQUIRES_SESSION403An agent key tried to loosen a boundary only a Privy session may loosen: raising or clearing a key’s spend cap (PUT /api/v1/agent/keys/:prefix/limits), or adding a withdrawal address.yes, with a Privy session

Hood has no browser wallet in the loop the way a client-signed launch would (there is no human present to sign a second, client-submitted transaction), but the launch is still signed by a real wallet the key owner controls: Privy signs and sends on behalf of the owner’s delegated embedded EVM wallet, server-side, the same wallet that would sign if the owner were sitting at a browser.

  • The on-chain creator is the key owner’s own wallet. CandleCurveFactory.createCurve hard-reverts CreatorMismatch unless the signer equals params.creator; since the owner’s wallet is now the one signing and sending (via Privy), setting creator to that same address satisfies this structurally, with no workaround needed. Gas, the dev buy, and every other cost come from that same wallet.
  • Exclusive Hood launches check the SAME wallet on-chain and off. The on-chain NFT gate checks creator at construction and msg.sender on every buy(), and both now resolve to the owner’s wallet — the exact wallet this API’s own eligibility check (whether the account may request mode: "exclusive" at all) already tests. The two checks finally agree; the only way they can still disagree is the owner’s NFT holding changing in the gap between the two checks (a transfer — Believer NFTs have no burn path today, but the on-chain gate still checks defensively).
  • Dev-buy tokens land in the owner’s own wallet, the same wallet that paid for them.
  • The dev buy is best-effort and slippage-protected. The curve is already deployed and recorded in Convex before the dev buy runs; a dev-buy failure is logged and does not fail or unwind the launch. The buy’s minAmountOut is computed from the curve’s own fresh config for exactly the amount sent, with 2% tolerance. If another buyer trades first and the dev buy would fall below that floor, it is skipped rather than sent; the owner keeps their ETH. Trades within the tolerance can still land, and the pre-send simulation cannot prevent a trade between simulation and inclusion.
  • A dev buy can outlive the request. The launch waits 60 seconds for the dev buy’s receipt. If none arrives, the launch still succeeds and the response says devBuy.outcome: "pending" with the hash in devBuy.pendingSignature; if the send itself failed in a way that may have gone out, it says "unknown". Either way the buy may still land. Do not place a replacement buy on that reading, or you may buy twice. Look pendingSignature up on chain, or read the job again (GET /api/v1/launch/headless/jobs/:clientLaunchId): Candle reads the chain for that buy for up to 24 hours, records it on the tape when it lands, and updates devBuy.outcome. Only reverted means no buy is outstanding; then place one via nextBuy if you still want it. An absent devBuy.signature alone is not a reason to buy again, and an absent devBuy.outcome proves nothing for a launch made before the field existed. A fee planned on a buy that lands after the request ended is not charged (devBuy.feeOutcome: "forgone"). The key’s USD spend limit keeps counting the buy while it is pending, unknown or lapsed.
  • The fee-splitter clone is still platform-deployed. Each launch’s per-launch CandleFeeSplitter clone (fee recipient, independent of creator) is deployed by the same backend relayer key that mints Believer NFTs, not the owner’s wallet — this one piece stays platform infrastructure, since the factory’s trust model depends on the clone being backend-vouched. The key owner is still the fee recipient the clone pays out to; only who deploys it is platform-side. (Streaming was retired 2026-09-14; a live streamer could once fill that slot instead.) Headless Hood launches pin the image and banner and check the owner’s native balance before obtaining a clone. The balance check currently refuses zero ETH only: a measured curve-creation gas budget is not yet available. It never requires the owner to fund the best-effort dev buy in advance. A definitive failure returns an unused clone for the same factory, dev and streamer. The next launch can reuse it only after chain reads confirm the factory vouches for it, its dev and streamer match, and its vault and split match the factory’s current settings. Ambiguous sends and landed launches keep their clone; only a proven revert can return a sent clone. A launch that paid for a deploy keeps its UTC-day slot even if the clone is later reused; a same-id retry on that day takes no extra slot, and a later-day retry takes that day’s slot subject to its cap.
  • dexVersion is required (v3 or v4); it selects which migrator the curve graduates through.
  • Quote assets: eth (native, the only one that supports a dev buy) and usdg.

Granting agent access attaches a Privy signer to the user’s embedded wallet. This app’s wallets are TEE-backed, so on-device delegation does not apply and two values must be set for an environment to support agent launches:

VariableWhereWhat it is
NEXT_PUBLIC_PRIVY_SIGNER_IDfrontendThe signer created in the Privy dashboard, attached to a wallet when the user enables agent access. An identifier, not a credential.
PRIVY_AUTHORIZATION_PRIVATE_KEYAPIAuthorizes this server to sign as that signer. Without it, wallet reads work and signing fails.

An environment missing the signer id reports agent access as unconfigured rather than failing at the Privy call with an error the user cannot act on.

The full field reference for the public market reads (market state, quote, verify, feed, agent profile) is Market state API. This section summarizes them.

The feed covers multiple launchpads. General trade quotes use POST /api/v1/trade/agent/quote; curve quotes and lifecycle data describe Candle markets. Indexed does not mean routable, and routable does not guarantee a quote or permission to trade. An external curve-only 404 directs you to the general endpoint; it is not a verdict that Candle cannot trade external tokens.

GET /api/v1/markets/:chain/:mint returns a cached (~5s) market for every same-chain indexed row, including non-routable external tokens. Every 200 includes candleLaunched, launchpad: string | null, venue: candle-curve | jupiter | dex | hood-dex | null, and trade: { endpoint: "POST /api/v1/trade/agent/quote", routable: boolean, reason?: string }. The reason is present only when false. Candle graduation before seeding reports solana_graduating / hood_graduating; recovery reports market_recovery; malformed Candle configuration reports market_configuration_missing. External false flags report not_routable_yet; identity-only fallback reports route_unverified.

Candle Solana migration uses the Jupiter rail while graduationVenue still names Meteora. Candle Hood migration uses dex; external Hood uses hood-dex. Venue is the stored routing family, never a quote adapter winner. No planner or vendor quote is called by this read. External lifecycle/curve/fee fields remain compatibility placeholders; feeBps: 0 is not free trading. Production launch provenance excludes only wrapped SOL, USDC and USD1; CNDL requires the same affirmative launch structure as any other token.

{
"success": true,
"market": {
"chain": "solana",
"mint": "...",
"lifecycle": "trading",
"buysOpen": true,
"sellsOpen": true,
"curveAddress": "...",
"poolAddress": null,
"quoteMint": "...",
"feeBps": 100,
"graduationVenue": "meteora-damm-v2",
"tier": "open",
"crossingModel": "full-fill-surplus",
"metadata": {
"name": "Example",
"symbol": "EXMPL",
"description": "...",
"image": "ipfs://bafkrei...",
"socials": { "twitter": "https://x.com/..." },
"onChain": false,
"version": null
}
}
}

lifecycle is one of trading, completed, migrated, recovery. crossingModel is full-fill-surplus on Solana or capped-refund on Hood. MARKET_NOT_FOUND is 404, with routing.reason (token_unknown, chain_mismatch and others) and discovery context. Dependency failures return retryable QUOTE_UNAVAILABLE. Forensics separately retains the legacy code and coverage contract.

metadata is the creator-supplied name, symbol, description, image, and socials, or null when we hold none. null is distinct from a creator having supplied an empty description.

onChain says who is authoritative:

  • false (all Solana tokens, and Hood tokens launched before the metadata factory cutover): these values come from Candle’s database. version is null. Solana metadata does exist on-chain, in the Metaplex account’s IPFS JSON, but that is a different mechanism from the contract fields this flag describes.

  • true (Hood tokens from the metadata factory): the token contract is authoritative and you can read it yourself:

    description() -> string
    image() -> string // conventionally ipfs://<cid>
    socials() -> string // JSON object, or "" when unset
    tokenURI() -> string // data:application/json;base64,... (name, symbol, description, image)
    creator() -> address // the only address permitted to revise metadata
    metadataVersion() -> uint64 // 0 = never edited since launch

    What this endpoint returns is our last recorded copy, served so most callers need one HTTP round trip rather than an RPC one. For a guaranteed-current read, call the contract.

Creator edits are permitted but never silent. setMetadata(string,string,string) is callable only by creator() and emits MetadataUpdated(address indexed editor, uint64 indexed version, string description, string image, string socials) on every revision, including version 0 at construction, so the full history and current state are reconstructable from logs alone.

Hood tokens launched before the cutover have no metadata fields, no setter, and no proxy, so they can never be retrofitted. Branch on onChain rather than probing the contract and interpreting a revert. version is null rather than 0 for them, because 0 already means “on-chain and never edited”.

The contract cutover landed at block 30442973. Hood curves created from that block onward report onChain: true; anything earlier reports onChain: false permanently, since those contracts have no metadata fields and cannot be retrofitted. Branch on the block, not on a date. See Networks and contracts for the contract addresses.

The response also carries a migration block:

"migration": { "status": "in_progress", "attempts": 3, "nextAttemptAt": 1754400120000 }

status is not_started (curve still trading), in_progress (curve done, venue not seeded; keeper attempt detail rides along on Hood), completed (with migratedAt, stamped from this release onward; older rows omit it), delayed (the Hood keeper exhausted its retries, gaveUpAt set, a human has been alerted), or cancelled (Hood Recovery, no pool will open). The SLA target is curve-complete to venue-live inside 10 minutes: the graduation keepers sweep every 2 minutes and alert on give-up, and delayed is the machine-readable form of that alert.

Hood Recovery is terminal: the market reports lifecycle: "recovery", both trade flags false, migration.status: "cancelled", and trade.reason: "market_recovery". Trade refusals carry market_recovery and retryable: false. No pool will open. The recovery block contains curve, quoteToken, reserveSnapshotRaw, eligibleSupplyRaw, surrenderedRaw, method: "claimRecovery(uint256)", and requiresApprovalTo (the curve). These are raw integer strings; payout for an amount is amount * reserveSnapshotRaw / eligibleSupplyRaw, rounded down. Approve the curve on the launched token, then call claimRecovery(amount) from the holder wallet.

GET /api/v1/markets/:chain/:mint/quote?side=buy|sell&amountIn=<base units>&slippageBps=50 (public, rate-limited 120/min per IP, cached ~5s) prices one trade through the exact on-chain math, so terminals do not re-implement crossing edge cases:

{
"success": true,
"chain": "hood",
"mint": "0x...",
"side": "buy",
"amountIn": "5000000000000000000",
"crossingModel": "capped-refund",
"quote": {
"amountOut": "...",
"fee": "...",
"minAmountOut": "...",
"crossesGraduation": true,
"refund": "...",
"quoteConsumed": "..."
}
}

All amounts are decimal strings in the relevant asset’s smallest unit. crossesGraduation is buys-only; on Hood a crossing buy is CAPPED with the remainder refunded (refund, quoteConsumed), on Solana it FILLS IN FULL and the surplus stays in the pool, so the refund keys never appear there. minAmountOut applies slippageBps to the capped fill, so it is always satisfiable on-chain. A curve that cannot execute the requested side returns MARKET_NOT_TRADABLE (409) whose message names where trading continues (Uniswap pool, Jupiter/Meteora route, or “buys and sells closed while graduating”).

GET /api/v1/verify/:chain/:mint (public, cached ~60s) answers “is this mint a genuine Candle launch”, so users and terminals can reject look-alike mints:

{
"success": true,
"candleLaunched": true,
"chain": "hood",
"mint": "0x...",
"tier": "open",
"quoteMint": "0x...",
"graduated": false,
"pool": null,
"createdAt": 1754400000000,
"creator": "0x...",
"viaAgentKey": true,
"provenance": { "curve": "0x...", "factory": "0x...", "configHash": "0x...", "dexVersion": "v4" }
}

An unknown mint, or one launched with non-production visibility, returns 200 { "candleLaunched": false } rather than a 404, so callers branch on one shape. Solana responses carry attribution (the bonding-curve program and attribution signer) so indexers can re-verify on-chain per the ecosystem docs; Hood responses carry the factory provenance block, re-verifiable against the registry.

GET /api/v1/launch/presets (public) returns first-party launch presets, each a named, fully resolved configuration joined with the live tier terms: solana-open-sol (the trend default), solana-exclusive-sol, solana-open-usdc, hood-open-eth-v4, hood-open-eth-v3, hood-open-usdg-v4. Expand one client-side into a normal launch body (the SDK’s expandPreset does this); the launch endpoint never sees preset names. The response and GET /api/v1/launch/quote-pairs both carry matrixVersion now, and quote-pairs serves a strong ETag with If-None-Match/304 support, so integrations can poll the matrix cheaply.

GET /api/v1/markets/feed?bucket=new|graduated|onfire|finalstretch&chain=solana|hood is a thin, public read onto the same feed buckets the trade page tabs use, optionally filtered to one chain, served live from the feed engine (no cache); 503 FEED_UNAVAILABLE with Retry-After while it starts. Each row carries chain, address, name, symbol, image, price/volume/market-cap stats, and isAgent (true for a Candle-origin launch created via an agent key).

GET /api/v1/users/:idOrWallet/agent is a public read: whether an account has agent features enabled, its address/username, its launch counts (total vs. those attributed to an agent key via launchesViaApi), and its display tier (max, pro or free, see Caps and plans above).

GET /api/v1/agent/tier (agent key or Privy session, any tier; read-only, never tier-gated, kill-switch-exempt like GET /limits) returns the calling account’s full tier picture in one call: the display and live tier, staked/held CNDL, the qualification thresholds and grace-window state, the account’s resolved platform fee, and lifetime fee totals by chain and quote asset. This is the same data the /agents console’s plan line and the /agents/pricing tier table render, and the SDK’s CandleClient.getAgentTier() wraps it directly. It also carries planTable (the same table GET /api/v1/agent/plans serves publicly), maxPricing and proPricing (null while Pro is not sold), tierCaps, checkedAt and maxExpired. When Max has expired, maxExpired is true and the response adds maxExpiredAt, maxExpiredSource, maxExpiredNotice (a sentence to show the user) and renewalUrl:

{
"success": true,
"tier": "pro",
"liveTier": "pro",
"stakedCndl": 600000,
"heldCndl": 0,
"thresholds": { "minStakedCndl": 500000, "minHeldCndl": 1000000, "graceMs": 0 },
"grace": { "active": false, "startedAt": null },
"maxTierExpiresAt": null,
"feeBps": 50,
"feeTotals": [{ "chain": "solana", "quoteAsset": "sol", "feeRawSum": "9007199254740993", "count": 3 }]
}

tier and liveTier resolve exactly per Caps and plans above (max > pro > free; neither reads believer). grace.active is always false and thresholds.graceMs is 0, and maxExpiredSource can read promo: the Max included with a first Pro purchase has ended (not reported while the account still pays for Pro). Treat an unknown source as a generic Max expiry. thresholds mirrors the same AGENT_TIER_PRO_MIN_STAKED_CNDL / AGENT_TIER_PRO_MIN_HELD_CNDL / AGENT_TIER_GRACE_MS env knobs documented there. grace.startedAt is null unless grace.active is true: a lapsed, non-requalified account can carry a stale grace-start timestamp internally, and this endpoint never reports it once the window is no longer the reason the account holds its tier. feeTotals[].feeRawSum is a raw-unit BigInt string (lamports, wei, and so on), never a JSON number; format it with the quote asset’s own decimals rather than calling Number() on it directly.

Signed push notifications for an account’s launches, token lifecycle, trades, transfers, and key and wallet changes, so an agent stops polling for outcomes. Registration, the event list, the delivery format, signature verification, secret rotation and the retry schedule are documented on Webhooks.

  • The launcher pays every lamport of a launch. The network fee and dev buy come straight from their wallet. The account costs are reimbursed: the launch builder prepends a SystemProgram.transfer from the launcher to the platform keypair for exactly what initialize_pool is about to spend:

    ItemLamports
    pool account (365 bytes)3,431,280
    mint (82 bytes)1,461,600
    3 program-created ATAs6,117,840
    metadata rent (607 bytes)5,115,600
    Metaplex protocol fee10,000,000
    Total26,126,320 (0.02612632 SOL)

    Rent is read from the cluster’s own schedule at build time rather than hardcoded, so a rent-schedule change cannot silently under-reimburse.

  • The transfer is ordered first, on purpose, so the platform keypair is credited before the program spends and needs no float of its own. Both instructions are in one transaction, so it is all-or-nothing: there is no ordering in which Candle pays rent without being repaid, and a launcher who cannot cover the total fails the whole launch rather than half-funding one.

  • Why a reimbursement and not a direct payment. The deployed program names authority (the platform keypair) as the payer on every account it creates, conflating a permission with a funder. Separating them needs a new payer account on initialize_pool, which is an on-chain upgrade. The program is upgradeable, but its source no longer matches any repo we have, so that route stays closed until the source is recovered. The practical difference is narrow: the platform remains the on-chain rent payer, so a future account closure would refund by the program’s rules, not the launcher’s.

The Candle MCP server exposes this API, trading and account reads as MCP tools; see MCP server.