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.
Which launch path
Section titled “Which launch path”There are three ways for an agent to launch. Only the first is open to every plan.
| Path | Plans | Who creates and pays | Buy at launch |
|---|---|---|---|
Headless launch: POST /api/v1/launch/headless, SDK launch(), MCP candle_launch_token and candle_launch_and_seed | Free, Pro and Max | The account’s embedded wallet, signed by Candle on the key owner’s behalf | A 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 Max | A linked wallet or a TEE wallet, signed by the agent | A 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 only | Creator 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 it | 1 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.
Agent access
Section titled “Agent access”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:
- 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.
- Delegate agent access. From the profile’s Agent access panel (or
directly via
@privy-io/react-auth’suseDelegatedActions().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. - Enable agent features and issue a key, as below.
POST /api/v1/agent/keyschecks 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:
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.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); otherwiseAGENT_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 rowGET /api/v1/agent/keysreturns) carrieslaunchChains: 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.
| Field | Notes |
|---|---|
environment | production (default) or test. See below for the test restriction. |
label | Optional display name, 1 to 64 characters after trimming. Renameable later via PATCH. With none, the dashboard derives a name from the key’s capabilities. |
expiresInDays | Optional integer, 1 to 365. Converted to an absolute expiresAt at creation. Mutually exclusive with expiresAt. |
expiresAt | Optional 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). |
txLimit | Optional { "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. |
ref | Optional 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". |
scopes | Subset 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 keyx-api-key: cndl_test_... # test keyGET /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.
Caps and plans
Section titled “Caps and plans”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:
| Free | Pro | Max | |
|---|---|---|---|
| Price | free | $50 a month | $200 a month |
| Agent trade fee | 1% | 0.5% | 0.25% |
| Perps builder fee | 0.1% | 0.1% | none |
| Requests per minute | 30 | 300 | 600 |
| Launches per day | 5 | 50 | 1,000 |
| Uploads per minute | 10 | 30 | 60 |
| Linked wallets | 0 | 10 | 1,000 |
| Trade Candle-launched tokens | yes | yes | yes |
| Trade base assets | yes | yes | yes |
| Base-pair swaps and own-wallet bridges (when enabled), no Candle fee | yes | yes | yes |
| Sell tokens not launched on Candle | yes | yes | yes |
| Buy tokens not launched on Candle | no | yes | yes |
| Hyperliquid perps (when enabled) | yes | yes | yes |
| Launch from a linked or TEE wallet (self-signed) | no | yes | yes |
| Atomic launch: launch + 1–4 first buys in one bundle | no | yes | yes |
| Create linked wallets | no | yes | yes |
| Import linked wallets | no | yes | yes |
| Limit orders | no | no | yes |
| Quant deep analyses (Max) | no | no | yes |
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:
| Plan | How |
|---|---|
| Free | agent 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:
| Variable | Default | What it is |
|---|---|---|
AGENT_TIER_PRO_MIN_STAKED_CNDL | 500000 | CNDL staked (sCNDL LP, converted at the pool ratio) at or above which an account qualifies for Pro. |
AGENT_TIER_PRO_MIN_HELD_CNDL | 1000000 | CNDL held (spot balance) at or above which an account qualifies for Pro, independent of staking. |
AGENT_TIER_GRACE_MS | 172800000 (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.
Test keys
Section titled “Test keys”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.
Kill switch
Section titled “Kill switch”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.
Linked wallets
Section titled “Linked wallets”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.
Import flow
Section titled “Import flow”Importing a wallet is a three-step, ciphertext-only exchange driven by the SDK’s
CandleClient.importWallet({ chain, address, privateKey, signerPublicKey, label? }):
POST /api/v1/agent/wallets/import/init(agent key,launch:write, Pro/Max only) returns Privy’s HPKE receiver public key (encryptionPublicKey) for the requestedchain(“solana” or “evm”) andaddress.- Client-side, never sent to Candle: the SDK’s
encryptWalletKeyForImportdecodesprivateKeyto its raw bytes (hex forevm, base58 forsolana, matching Privy’s own reference decode byte-for-byte) and HPKE-seals those bytes toencryptionPublicKey— 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. POST /api/v1/agent/wallets/import/submitposts onlyciphertext,encapsulatedKey, andsignerPublicKey(never the private key itself, plaintext or otherwise). The server then, in order: registers a 1-of-1 Privy signer quorum fromsignerPublicKey; 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.
Spend limits (per-key)
Section titled “Spend limits (per-key)”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:
- 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. - 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.
Revocation
Section titled “Revocation”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 samekeysAuthpair 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 holdsswap:write(REBIND_TARGET_INVALIDwithreason).launch:writeis not required: a wallet withallowLaunchbound to a key without it simply cannot launch. A paused key is allowed and reported aspaused: true. - Each wallet is an active TEE wallet at
verified-activeremote authority (REBIND_WALLET_INVALIDwithreason:not_found,not_tee,inactive,authority_not_verified). A wallet already bound to the target isunchanged, 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) withwalletIdsandretryAfter; 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 itslastValidBlockHeight) isREBIND_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 answersREBIND_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) isREBIND_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.
The never-plaintext rule
Section titled “The never-plaintext rule”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.
Env knobs
Section titled “Env knobs”| Variable | Default | What it is |
|---|---|---|
AGENT_TIER_LINKED_WALLETS_PRO | 10 | Max active linked wallets a Pro-tier account may hold at once. |
AGENT_TIER_LINKED_WALLETS_MAX | 1000 | Max active linked wallets a Max-tier account may hold at once. |
AGENT_WALLET_CEILING_LAMPORTS | 50000000000 (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_WEI | 10000000000000000000 (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.
Self-signed launches
Section titled “Self-signed 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.
-
POST /api/v1/launch/self/build— the same body/launch/headlesstakes (see Request below) plus a requiredlinkedWalletIdnaming 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
mintyet (see Build/confirm mechanics below):{ "success": true, "transaction": { "to": "0x...", "data": "0x..." }, "curveAddress": "0x...", "clientLaunchId": "...", "expiresAt": 1754401800000 } -
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 viabuildPrivyAuthorizationSignature), which Candle never sees, and callsPOST /api/v1/agent/wallets/:id/sign(swap:writescope). 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. RequiresprivyAppId(a PUBLIC Privy identifier, the same value the app exposes asNEXT_PUBLIC_PRIVY_APP_ID, not a secret) configured on the client, matching the app id the relay’s server authenticates under. -
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 sameclientLaunchId:{ "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
/buildproduced, before recording the launch exactly like/launch/headlessdoes (token row, activity, webhooks). The response is the same shape/launch/headlessreturns (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.
What Candle never does on this path
Section titled “What Candle never does on this path”- 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.
/buildreturns an unsigned payload;/confirmonly 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.
Payer rules
Section titled “Payer rules”linkedWalletId must resolve, for the calling account, to a linked wallet that is:
- Active. Not revoked —
DELETE /api/v1/agent/wallets/:idtombstones 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(beforebegin, so a bad id never consumes aclientLaunchId) 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).
Build/confirm mechanics
Section titled “Build/confirm mechanics”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.
Ledger and counter semantics
Section titled “Ledger and counter semantics”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
/buildcall first sweeps the CALLING account’s own stale builds: anysubmitted, 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/buildagain 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
/confirmwith 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./confirmanswersJOB_NOT_FOUNDfor 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 answersVALIDATION_FAILED, retryable, as for a live row. -
Same-
clientLaunchIdretry. Calling/buildagain with the sameclientLaunchIdand the same body (including the samelinkedWalletId, which is part of what gets hashed for idempotency) replays the same attempt. If it is alreadyconfirmed,/buildanswersIDEMPOTENCY_CONFLICTand/confirmreturns the stored success outright, without touching the chain or Convex again. A DIFFERENT body (including a differentlinkedWalletId) against the sameclientLaunchIdisIDEMPOTENCY_CONFLICT, same as headless.On Solana, when the linked wallet broadcasts the launch itself, one
clientLaunchIdhas one build at a time, and the chain decides what a repeat/buildanswers. 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 /buildanswersWhat to do Landed: an account exists at its mint 409 IDEMPOTENCY_CONFLICT,retryable: false,error.reason: "prior_attempt_on_chain",error.mintRecord it with /confirmand 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),/confirmcannot succeed: launch under a newclientLaunchId.Can still land 200 with the SAME response as the first call: the same transaction,mint,pool,expiresAtandfeeBroadcast 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 /buildagain afterretryAfter.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 height 200 with a NEW transaction, a new mintand a newexpiresAtSign and broadcast the new one. The old one can never land. The chain could not be read LAUNCH_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
expiresAtis 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/buildcalls for one id return one transaction: the other answersLAUNCH_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
/buildreturns 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
/buildassembles, 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/buildis 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—createCurveis non-payable, so/buildnever assembles it at all — which means the relay now refuses it outright withUNRECOGNIZED_TRANSACTION, before any Privy call, regardless of whatbuyAmountwas 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-accountethDENY rule ever was, not looser. - The delegated (main-wallet) Hood dev buy is a different path, unaffected by any of this. A
/launch/headlessdev 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.
Quorum rotation (v1)
Section titled “Quorum rotation (v1)”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.
walletAddress casing
Section titled “walletAddress casing”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.
Hood: a failure after createCurve landed
Section titled “Hood: a failure after createCurve landed”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.
| Field | Meaning |
|---|---|
clientLaunchId | The id this call built under, yours or the generated one. |
createCurveTxHash | The mined createCurve transaction. It is the signature a confirm takes. |
chain, stage | Always "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. |
feeOutcome | not-required, not-broadcast, reverted, unknown or landed. See the table below. |
feeTxHash | Every fee hash the call learned, including a refused or reverted one. Absent when none is known. A hash alone does not prove payment. |
cause | The 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.
step | feeOutcome | What happened | What to do |
|---|---|---|---|
confirm | landed | The 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. |
confirm | not-required | The build had no fee leg. The confirm was refused, or its answer was lost. | The same, with no feeTxHash. |
feeTransfer | unknown | The 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. |
feeTransfer | not-broadcast | Gas 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. |
feeTransfer | reverted | The 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. Aconfirmedjob can be confirmed again: it replays the stored success. - The fee is still required. When the build stamped a nonzero fee,
/confirmanswers 402FEE_LEG_MISSING,retryable: false, for a missingfeeTxHashor one that does not verify, and records nothing. AcreateCurvehash does not waive it, and an unchanged confirm gets the same answer: it needs valid fee evidence first. A build with no fee confirms withoutfeeTxHash. - Completing a missing fee uses the original build’s
feeTransferandfee(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 unlessfeeOutcomesaysnot-broadcastorreverted. - Some rows cannot be recorded with
/confirm. If the build was replaced, or the row was failed by the 30-minute sweep,/confirmcan answerJOB_NOT_FOUNDfor valid hashes. Keep the hashes and contact Candle. The error keeps the evidence; it does not promise the row can be booked. - Before the
createCurvereceipt nothing changed. An error there is aCandleApiError, aJsonRpcErroror aCandleOperationErroras before, including acreateCurvesend 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: aftercreateCurveis mined, keep its hash and never build again.
Errors
Section titled “Errors”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).
Atomic launch with first buys
Section titled “Atomic launch with first buys”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.
-
POST /api/v1/launch/atomic/build— the same body/launch/headlesstakes (see Request above), withoutbuyAmount(a nonzerobuyAmounthere is rejectedVALIDATION_FAILED: an atomic launch never bundles a dev buy into the launch transaction itself — give the creator a first buy viafirstBuysinstead, so it lands as bundle leg 1 against a still-virgin curve, see “The pricing ladder” below), pluspayer(who creates the token) andfirstBuys(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
payeris{ "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, andunsignedTxBase64is omitted entirely. A"client"signer (a"linked"payer) carriesunsignedTxBase64for the agent to sign itself. -
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. -
POST /api/v1/launch/atomic/submit—bundleIdplussignedTxsBase64, containing exactly the client-signer legs/buildnamed, 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
successfield:-
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/buildreturned them. Every leg’s own fee, amount, and fill are already exactly what/buildrecorded — a Jito bundle is all-or-nothing, so nothing here needs the balance-delta verification a broadcast/confirmelsewhere 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;missingnames it (token,finalization,launch,buy:<clientTradeId>). Nothing needs resending and nothing was lost: Candle completes it without a new transaction, andGET /api/v1/launch/atomic/bundles/{bundleId}reports it. An older client that ignoresbookingreads 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 }retryableisfalseon 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 newclientLaunchIdbecause of one: that is a second token. The bundle is never sent a second time: sending the samebundleIdto/submitagain answers that bundle’s own outcome once it is known (and the same"timeout"until then). Recovery goes through the launch’s ownclientLaunchId: readGET /api/v1/launch/headless/jobs/{clientLaunchId}(it needslaunch:read), then send/buildagain with the sameclientLaunchIdand body when the job says to. “After a timeout, a failure or a lost response” under SDK below has the steps. A same-id/buildafter 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 samebundleIdmay be submitted again, corrected, until the build expires. A second submit while one is still in progress is 409IDEMPOTENCY_CONFLICTwithretryable: true.
-
-
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}]}}operatorStateis one of:State Meaning 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
buyscarries its ownoperatorState. A buy that landed is reportedlanded_pending_bookingeven where its trade row still readsfailedwithTRADE_BUILD_EXPIREDfrom before 2026-10-13 (expiredLabelSuperseded: true): the row is stale, the buy is on chain. A buy whose own transaction failed on chain is reportedreverted: it is final, itserrorCodeisSLIPPAGE_EXCEEDEDorTRADE_REVERTED, nothing is owed for it, and the bundle can still reachbooked. The response also carriesattemptState,keyPrefix,chainOutcome(withrevertedLegs, each failed buy’s signature and code, when there are any),unknownPhases,lastError,lastErrorPhase,lastAttemptAtandrecoveryAttempts. An unknownbundleId, or one built by another account, is 404JOB_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
/submitor same-id/buildreconciled 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’sactualOutRaw, the activity row and thetrade.executedwebhook’sexpectedOutRawreport. A buy whose delivered amount cannot be read yet stayslanded_pending_bookingand 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_EXCEEDEDwhen the program refused the fill below its minimum, orTRADE_REVERTEDfor any other on-chain failure, and its spend hold is returned. The recovery view shows that buy asreverted, 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.
Jito configuration
Section titled “Jito configuration”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.
Spend limits and the key transaction cap
Section titled “Spend limits and the key transaction cap”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.
bundleId lifetime and blockhash liveness
Section titled “bundleId lifetime and blockhash liveness”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.
Errors
Section titled “Errors”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:
- Read
getLaunchJob(clientLaunchId). This needslaunch:readon 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 addlaunch:readto any key that runs atomic launches. A scope refusal (SCOPE_MISSING) is not “not found”. confirmed: the bundle landed. The mint is on the job. Stop.- 404
JOB_NOT_FOUND: the build never began. CalllaunchAtomic()again with the same id. failed: the bundle did not land. CalllaunchAtomic()again with the same id; the server starts a clean attempt under it.- 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 409IDEMPOTENCY_CONFLICTwhile 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. - 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. - One state has no terminal answer today. For an attempt whose blockhash expired with no
signature on record, the same-id
launchAtomic()keeps answering 409IDEMPOTENCY_CONFLICT, “This launch was sent; its original attempt must be resolved”, and the job read is still neitherconfirmednorfailed. 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 theclientLaunchIdandbundleIdto whoever operates the key. - Treat every
"timeout"as unresolved.retryableisfalseon 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):
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": [...] }Request
Section titled “Request”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.
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" }'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" }'| Field | Required | Notes |
|---|---|---|
clientLaunchId | yes | Idempotency key, unique per account. 1-128 chars. See Idempotency below. |
chain | no | solana (default) or hood. |
quoteAsset | no | Defaults to the chain’s default (sol on Solana, eth on Hood). Solana: sol, usdc, cndl. Hood: eth, usdg. |
mode | no | open (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. |
curve | exp-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. |
stakerAllocationBps | no | Integer, 50-1000 (0.5%-10%). Defaults to 50, or 500 for the exp- modes, whose curve is derived from it. |
dexVersion | Hood only | v3 or v4, required for chain: "hood". Rejected (VALIDATION_FAILED) if set for Solana. |
buyAmount | no | Initial 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, symbol | yes | UTF-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). |
imageUrl | yes | https 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. |
bannerUrl | no | https 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. |
description | no | Same JSON-safety rule as name (VALIDATION_FAILED). |
socials | no | Object of strings; only twitter, telegram, website, discord are used. Each value must be an http(s) URL (VALIDATION_FAILED). |
visibility | no | production (default), test, local, hidden. Test keys are restricted, see above. |
async | no | Boolean, 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:
| Rule | SOL pair | CNDL pair | Refused with |
|---|---|---|---|
| Outer limits | V from 1 to 100 SOL, G from 1 to 250 SOL | V from 66,000 to 6,600,000 CNDL, G from 66,000 to 16,500,000 CNDL | CURVE_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 stakerAllocationBps | the same | CURVE_OUT_OF_RANGE, reason: "price_ceiling", with maxThresholdRaw: the largest G, raw, that fits this V at this allocation |
| Precision | at most 9 decimals | at most 6 decimals | VALIDATION_FAILED, reason: "curve_precision" ("curve_format" for anything that is not a plain decimal), field curve.virtualQuote or curve.threshold. Never rounded |
| Dev buy | buyAmount at most 10% of G, and never above the ordinary ceiling | the same | DEV_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:
| Field | Meaning |
|---|---|
symbol, virtualQuoteRaw, thresholdRaw, raise | The quote asset, V and G raw, and G in whole units. |
startFdv, bondingFdv | Market cap at the starting price and at graduation, in the quote asset, on the whole supply. |
supplySoldPct | Share of the supply sold by graduation, percent. |
firstBuyRaw, firstBuySupplyPct | How 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.
Hosted images
Section titled “Hosted images”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:
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:
candle launch --name Demo --symbol DEMO --image ./logo.png --wallet agent # upload, then launchcandle image upload ./logo.png # upload only; prints the URLcandle 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-urlstill takes anhttps://URL.candle image upload <path>prints the hosted URL and does nothing else. Pass that URL asimageUrlto any launch route here, to the SDK, or to a connector tool.- The local connector calls it too, from
@candledottv/mcp0.10.12, where its operator setCANDLE_IMAGE_DIR:candle_launch_tokenandcandle_launch_and_seedthen acceptimagePath, 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 takesimageUrlonly.
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.
Idempotency
Section titled “Idempotency”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 newclientLaunchId. - 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
409above, 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 newclientLaunchId. - A row stuck
submittedalso 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.
Dry run
Section titled “Dry run”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 field | Notes |
|---|---|
txBytes | The assembled transaction’s byte count for this exact request (name, symbol, dev buy, and fee leg included). |
limit | Solana’s raw packet limit, 1232 bytes. |
maxNameBytes | The 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. |
fits | Always 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.
Self-signed dry run
Section titled “Self-signed dry run”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.
Async launches and jobs
Section titled “Async launches and jobs”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.
Response
Section titled “Response”{ "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": "..." } }}| Field | Notes |
|---|---|
pool | null when the launch has no pool address yet (e.g. an in-flight replay). |
matrixVersion | The launch economics matrix version this launch resolved against. Echoed so integrators can tell which curve/fee terms applied. |
links.explorer | Always present on Solana (Solscan). On Hood, present only when HOOD_EXPLORER_URL is configured server-side; omitted otherwise. |
nextBuy.market | The pool address, or the mint when there is no pool yet. Where an agent places its next buy. |
nextBuy.marketStateUrl | Relative path; prefix with your API_URL. See Market state below. |
devBuy.signature | Hood 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.outcome | Hood 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.pendingSignature | The 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.feeOutcome | The 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. |
Errors
Section titled “Errors”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.
| Code | Status | Meaning | Typically retryable |
|---|---|---|---|
VALIDATION_FAILED | 400 | Generic field validation failure (see field). | no |
INVALID_NAME_BYTES | 400 | name exceeds 32 UTF-8 bytes. | no |
INVALID_SYMBOL | 400 | symbol exceeds 10 UTF-8 bytes. | no |
IMAGE_TOO_LARGE | 400 | imageUrl resolves to a file over 10MB. | no |
IMAGE_UNREACHABLE | 400 | imageUrl did not resolve or respond in time. | no |
IMAGE_REDIRECT_NOT_ALLOWED | 400 | imageUrl issued a redirect; not followed. | no |
BANNER_UNREACHABLE | 400 | bannerUrl did not resolve or respond in time. | no |
BANNER_WRONG_SHAPE | 400 | bannerUrl is not wider than 1.5:1 (see the bannerUrl row above). | no |
DEV_BUY_UNSUPPORTED_FOR_PAIR | 400 | Non-zero buyAmount against a quote asset that does not support a dev buy. | no |
DEV_BUY_TOO_LOW | 400 | Retained 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_HIGH | 400 | buyAmount 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_UNKNOWN | 400 | quoteAsset is not a known pair for chain. | no |
CURVE_OUT_OF_RANGE | 400 | exp-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_ELIGIBLE | 403 | mode: "exclusive" requested but the account is not Believer-NFT eligible. | no |
UNAUTHORIZED | 401 | Missing, malformed, unknown, or revoked API key. | no |
SCOPE_MISSING | 403 | The key lacks the scope this route requires. | no |
EMBEDDED_WALLET_NOT_PERMITTED | 403 | The 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_MISMATCH | 403 | Self-signed launches: the confirmed transaction’s signers do not include the linked wallet that built it. | no |
FEE_LEG_MISSING | 402 | Self-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_EMBEDDED | 403 | The 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_DELEGATED | 403 | An 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_REVOKED | 403 | Delegation 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_BALANCE | 402 | The 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_LARGE | 400 | The 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_FOUND | 404 | No market for that chain/mint pair (market-state endpoint only). | no |
IDEMPOTENCY_CONFLICT | 409 | Same clientLaunchId reused with a different body (not retryable), or the launch is still in flight (retryable). | sometimes, see Idempotency |
RATE_LIMITED | 429 | The 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_REACHED | 429 | Account’s daily launch cap reached. | yes, next day |
LAUNCH_FAILED | 500 | Build, broadcast, or confirmation failed (includes confirmation timeout, see Idempotency). | yes, same clientLaunchId |
LAUNCH_DISABLED | 503 | HEADLESS_LAUNCH_ENABLED is not "true" server-side. | no |
HOOD_LAUNCH_UNAVAILABLE | 503 | Hood 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_REQUIRED | 403 | The 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_REACHED | 400 | The account already has the maximum active linked wallets for its tier; revoke one first. | yes, after revoking one |
KEY_EXPIRED | 401 | The 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_REACHED | 403 | The 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_EXCEEDED | 400 | A 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_FORBIDDEN | 403 | A test-environment key called a rail that has no non-production mode (the trade rail, atomic launches). | no |
BUILD_TIMEOUT | 504 | A build step exceeded its deadline (quote, simulation, or bundle assembly). | yes |
UNRECOGNIZED_TRANSACTION | 403 | The 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_DENIED | 403 | The owner wallet’s own Privy policy rejected the transaction. | no |
AGENT_WALLET_MISSING | 400 | Main payer: the account has no embedded wallet for that chain. | no |
SWAP_FAILED | 500 | An unclassified failure executing or recording a swap leg. Message states whether the transaction may already be on-chain. | sometimes, see the message |
JOB_NOT_FOUND | 404 | The clientLaunchId/clientTradeId does not resolve to a live row: never built, or swept after expiry. | no, rebuild |
KEY_ISSUANCE_FAILED | 503 | A dependency needed to issue the key was unreachable (Convex, Privy, or the on-chain Believer check). | yes |
IMAGE_WRONG_SHAPE | 400 | The uploaded image is not square, or violates the size bounds. | no, change the file |
UPLOAD_INVALID | 400 | The upload body was malformed or the content type is unsupported. | no |
WEBHOOK_LIMIT_REACHED | 400 | The account already has the maximum registered webhook endpoints. | yes, after deleting one |
WALLET_ALREADY_LINKED | 409 | This address already has an active linked-wallet row, on this account or another one. | no |
LOOSEN_REQUIRES_SESSION | 403 | An 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 notes
Section titled “Hood notes”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
creatoris the key owner’s own wallet.CandleCurveFactory.createCurvehard-revertsCreatorMismatchunless the signer equalsparams.creator; since the owner’s wallet is now the one signing and sending (via Privy), settingcreatorto 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
creatorat construction andmsg.senderon everybuy(), and both now resolve to the owner’s wallet — the exact wallet this API’s own eligibility check (whether the account may requestmode: "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
minAmountOutis 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 indevBuy.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. LookpendingSignatureup 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 updatesdevBuy.outcome. Onlyrevertedmeans no buy is outstanding; then place one vianextBuyif you still want it. An absentdevBuy.signaturealone is not a reason to buy again, and an absentdevBuy.outcomeproves 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 ispending,unknownorlapsed. - The fee-splitter clone is still platform-deployed. Each launch’s
per-launch
CandleFeeSplitterclone (fee recipient, independent ofcreator) 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. dexVersionis required (v3orv4); it selects which migrator the curve graduates through.- Quote assets:
eth(native, the only one that supports a dev buy) andusdg.
Agent access configuration
Section titled “Agent access configuration”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:
| Variable | Where | What it is |
|---|---|---|
NEXT_PUBLIC_PRIVY_SIGNER_ID | frontend | The signer created in the Privy dashboard, attached to a wallet when the user enables agent access. An identifier, not a credential. |
PRIVY_AUTHORIZATION_PRIVATE_KEY | API | Authorizes 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.
Market state
Section titled “Market state”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
Section titled “Metadata”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.versionisnull. 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() -> stringimage() -> string // conventionally ipfs://<cid>socials() -> string // JSON object, or "" when unsettokenURI() -> string // data:application/json;base64,... (name, symbol, description, image)creator() -> address // the only address permitted to revise metadatametadataVersion() -> uint64 // 0 = never edited since launchWhat 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”).
Verify
Section titled “Verify”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.
Presets
Section titled “Presets”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).
Agent profile
Section titled “Agent profile”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).
Agent tier snapshot
Section titled “Agent tier snapshot”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.
Webhooks
Section titled “Webhooks”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.
Operational notes
Section titled “Operational notes”-
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.transferfrom the launcher to the platform keypair for exactly whatinitialize_poolis about to spend:Item Lamports pool account (365 bytes) 3,431,280 mint (82 bytes) 1,461,600 3 program-created ATAs 6,117,840 metadata rent (607 bytes) 5,115,600 Metaplex protocol fee 10,000,000 Total 26,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 oninitialize_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.