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.
See docs/partner-api-changelog.md for the dated
breaking-change list if you integrated against v1.
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
docs/agent-trading.md. It reuses this doc’s Auth,
tiers, and Linked wallets sections unchanged.
Every headless launch is signed and paid for by the key owner’s own 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. See
docs/partner-api-changelog.md for the dated entry.
Every account manages its own agent keys from the Candle site
(/dev/agent) 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.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 own cumulative USD trading volume over that window; exceeding it is KEY_LIMIT_REACHED (403) at trade build. |
ref | Optional referral code. Attributes the account to that referrer once, best-effort, and never blocks issuance. A referred account then pays 10 percent less on the platform fee for every trade (launch dev buys are not discounted). |
scopes | Subset of launch:write, launch:read, activity:write, swap:write. Defaults to the first three; swap:write is opt-in only and never granted by omission, since it moves real funds through the owner’s own wallet on every call. launch:read is enforced by the launch-jobs endpoint (GET /api/v1/launch/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 docs/agent-trading.md. |
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 docs/agent-trading.md.
Caps and trust tiers
Section titled “Caps and trust tiers”A key’s row caps are set once at issuance, from whether the account’s wallet held a Believer NFT at that moment. On top of that, every account has a live tier that is re-evaluated continuously, and the cap actually enforced on each request is the higher of the two:
| Tier | Gate | Requests/min per key | Launches/day per account | Image uploads/min (per key) | Linked wallets (max active) | Spend limits |
|---|---|---|---|---|---|---|
| Free | agent features enabled | 30 | 5 | 10 | 0 | per-key, default none |
| Believer | Believer NFT at key issuance | 30 | 20 | 10 | 0 | per-key, default none |
| Pro | 500k CNDL staked or 1M CNDL held (live, 48h grace) | 300 | 50 | 30 | 10 | per-key, default none |
| Max | contact us | 600 | 1,000 | 60 | 1,000 | per-key, default none |
Spend limits are not a tier perk: every key starts fully unlimited, and any tier can cap its
own keys (see Spend limits below). Linked wallets are covered in full in the Linked
wallets section below; Free and Believer accounts cannot import or link a wallet at all
(TIER_REQUIRED).
The launch cap is per account (multiple keys never multiply launches) and
resolves as max(key row, live tier), so an individually raised key is never
lowered. The daily launch cap is a UTC calendar-day counter: failed launches
are refunded, in-flight launches count, and the window resets at 00:00 UTC.
Pro is re-evaluated continuously with a 48-hour grace window after the
balance drops below threshold; Max is granted manually with an expiry, via
POST /api/v1/admin/agent-tier (admin-only).
Believer differs from Free only in the daily launch cap frozen onto the key row at issuance: Believer is a display label for “held a Believer NFT at issuance,” not a live-evaluated tier, and it never regresses even if the NFT is later transferred away.
The live tier’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. |
An account may hold at most 5 active keys. Revoke an unused one before
issuing a 6th; 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-tier accounts can attach additional wallets to their Candle identity, beyond the primary Privy wallet delegated for 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 tier (see the Linked wallets column above) and revocable at any time.
Free and Believer accounts cannot use either path (TIER_REQUIRED, 403).
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
encryptWalletKeyForImport(packages/sdk/src/wallet-import.ts) decodesprivateKeyto 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)”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 caps are set at issuance (spendLimits on POST /keys, or the CLI’s
--tx-limit for the USD volume cap) or later via PUT /api/v1/agent/keys/:prefix/limits; 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
docs/agent-trading.md).
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. checkSpendAgainstLimits (apps/api/src/lib/spend-limit-gate.ts), 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
docs/agent-trading.md), 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.
See buildBackstopPolicy in apps/api/src/lib/agent-policy.ts for the exact rule shapes, and
docs/runbooks/agent-pilot-runbook.md’s “Spend-limits Phase A” section for the deploy-ordering rule and
migration script that moves an already-imported wallet from the old per-limit policy onto this
one, plus the V1-V4 live checks that gate relying on it against real wallets.
DELETE /api/v1/agent/wallets/:id``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.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
(scripts/verify-privy-policies.mjs’s checkCpiCoverage, run 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.
See agent-policy.ts’s header comment for the full finding.
Pre-enable verification
Section titled “Pre-enable verification”The rule shapes above (apps/api/src/lib/agent-policy.ts) are checked against Privy’s SDK
types and wire-level schema, but that only proves Privy will ACCEPT them, not that Privy’s
policy engine evaluates them the way the code assumes. scripts/verify-privy-policies.mjs
is the live check: given real credentials for a Privy app, it creates throwaway policies and
app-owned wallets (all display_name’d verify-script-throwaway, never funded), attempts
signing calls against them, and confirms deny-all-on-empty-rules, the permissive-base ALLOW,
DENY-overlay caps, AND-vs-OR condition semantics, numeric (not lexicographic) comparison, the
System-program instruction-name spellings, and CPI/program-routed transfer coverage (see “What
a cap actually bounds” above) all behave as expected — all seven of these are PASS/FAIL. Two
remaining items (import address-derivation, a USDG ABI selector sweep) cannot be automated and
print as MANUAL checklist entries instead.
It is a manual, run-by-hand check, not part of bun run ci: run it once before enabling live
agent trading on a Privy app.
PRIVY_APP_ID=... \PRIVY_APP_SECRET=... \PRIVY_AUTHORIZATION_PRIVATE_KEY=... \bun run scripts/verify-privy-policies.mjsWith any of those three env vars unset, it prints a message and exits 0 without verifying anything, so it never fails a build that has not opted in.
The backstop policy (see “The wallet policy: a fixed backstop, not per-asset caps” above) reuses
the same primitives this script verifies (the permissive base, DENY-overlay semantics, the CPI
blind spot), plus its own migration (scripts/migrate-agent-policies-to-backstop.mjs) to move an
already-imported wallet onto it. docs/runbooks/agent-pilot-runbook.md’s “Spend-limits Phase A” section is
the pre-enable checklist for the backstop specifically: the hard deploy-ordering rule (the
build-bound relay must be live before the migration ever runs), the migration command, and the
V1-V4 live checks that gate relying on it against real wallets.
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.
Because attribution reads through the 60-second cache mentioned above, a just-revoked wallet
can still attribute a trade for up to 60 seconds after the revoke call returns.
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 (Spend-limits Phase A) — 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 (see docs/runbooks/hood-deploy.md).
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 (apps/api/src/routes/agent.ts). For a Solana-linked wallet, the
SDK’s CandleClient.selfLaunch() (packages/sdk/src/client.ts) 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,packages/sdk/src/authorization-signature.ts), 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).
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. 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(packages/sdk/src/secret-store.ts, 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 by a Hood-specific
recordHoodBuildArtifacts mutation (see Convex note below), 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. - 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 stillsubmittedand unexpired,/buildrebuilds and returns a fresh unsigned transaction for the same mint/pool; if it is alreadyconfirmed,/confirmreturns the stored success outright, without touching the chain or Convex again; if it already failed or expired, the next/buildcall starts over cleanly. A DIFFERENT body (including a differentlinkedWalletId) against the sameclientLaunchIdisIDEMPOTENCY_CONFLICT, same as headless.
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 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
docs/agent-trading.md): 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 delegated wallet signs server-side through Candle’s own owner-signer (seedocs/headless-launch.md’s Request table,DEV_BUY_MAX_WEI) and is bounded by that per-pair ceiling plus the server-sidemain-scopecheckSpendAgainstLimitsgate — never a Privy policy, since a delegated 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
docs/agent-trading.md): it was true before Spend-limits Phase A and remains true after, 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 a fresh policy compiled from the account’s current spend
limits, the same 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.
Convex
Section titled “Convex”New on headlessLaunches (packages/db/convex/schema.ts): selfSigned/builtAt (the build
stamp and lazy-expiry anchor), selfLinkedWalletId/selfVisibility/selfBuyAmount (the Solana
build/confirm handoff), and fifteen selfHood* fields (the Hood handoff: fee recipient, CREATE2
salt, dex version, staker allocation, staking distributor, factory, registry, quote asset, mode,
name, symbol, description, image, socials, streamer address, and whether it was created during a
stream). New functions: headlessLaunches.recordBuildArtifacts (Solana build stamp),
headlessLaunches.recordHoodBuildArtifacts (Hood build stamp), headlessLaunches. expireStaleSelfBuilds (the lazy sweep above), and linkedWallets.getById (an owner-scoped
read-only twin of revoke, used to re-verify the payer at both /build and /confirm). Verified
against a local anonymous Convex deploy. Vercel preview deployments run the staging Convex
backend and do not apply a PR’s own schema, so a preview URL will not reflect any of this until
the branch reaches staging; verify locally instead.
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;
IDEMPOTENCY_CONFLICT; 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.
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 delegated 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" } },{"index": 2,"role": "buy","signer": "client","unsignedTxBase64": "<base64>","expectedFill": { "amountOutRaw": "238014455" }}],"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>"]}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.502, failed or timed out:{ "status": "failed", "bundleId": "b7e2b4b0-6b1a-4e0e-9c2a-7b6f0e2f9e21", "retryable": false }{ "status": "timeout", "bundleId": "b7e2b4b0-6b1a-4e0e-9c2a-7b6f0e2f9e21", "retryable": true }retryableis alwaysfalsefor"failed". For"timeout"it istrueonly when Candle could prove the bundle’s shared blockhash expired with no confirmation ever observed;falsemeans the resolution window simply ran out with no definitive answer — the bundle might still land, so wait rather than immediately relaunch under a freshclientLaunchId. Either way,bundleIditself is dead the moment this response arrives (see “bundleId lifetime” below) — recovery is always a fresh/buildcall, never a retried/submitwith the same id.- Everything else (400/403/404/501/503) is the standard error envelope this API uses
everywhere (
{ "success": false, "error": { "code", "message", ... } }); see Errors below.
The pricing ladder, and why minAmountOut is always "0"
Section titled “The pricing ladder, and why minAmountOut is always "0"”Every buy leg’s own on-chain swap instruction carries minAmountOut: 0 — no slippage guard at
all. That is deliberate, not an oversight: the bundle is atomic and internally ordered. Only
your OWN earlier legs in this same bundle can move the price before a later leg executes (leg 1
sees the virgin pool the launch transaction just created; leg 2 sees whatever leg 1’s swap left
behind; and so on) — nothing external can land in between, land first, or land at all without
every transaction in the bundle landing together. There is no third party whose front-run a
slippage floor would need to defend against, the way there is for an ordinary standalone trade.
/build’s response carries the real protection instead: expectedFill.amountOutRaw on each buy
leg is an advisory fill computed sequentially against the SAME curve math the program itself uses
(so leg 2’s advisory number already accounts for leg 1’s simulated impact) — 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.
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, silently mispricing every advisory fill 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
docs/agent-trading.md), 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 docs/agent-trading.md) is checked
the same way: once, against the SUMMED value of every buy leg in the bundle (not per leg),
using the same checkKeyTxLimit call the trade rail uses — every leg meters against the same key
at settlement, so checking each leg against remaining headroom independently would let a bundle
land several times its actual remaining budget in one atomic event. A bundle whose combined buys
would push the key’s own windowed usage over its cap fails KEY_LIMIT_REACHED (403) at /build,
with the same resetsAt/retryable shape documented there. Both checks run before anything is
built, so a bundle that fails either never 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
docs/agent-trading.md: 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), 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), 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.
packages/sdk/src/client.ts’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 SubmitAtomicLaunchResult untouched: "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. 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).
import { CandleClient } from "@candledottv/agent-sdk"
const client = new CandleClient({ apiUrl: "https://api.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" -- see "bundleId lifetime and blockhash liveness" above before rebuilding. console.log(`Bundle ${result.bundleId} did not land: ${result.status} (retryable: ${result.retryable})`)}A worked curl round trip (main payer throughout, so there is no signing step):
BUILD=$(curl -s -X POST https://api.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.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”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. |
stakerAllocationBps | no | Integer, 50-1000 (0.5%-10%). Defaults to 50. |
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. 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 (Phase 2, R5; 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). |
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 | |
socials | no | Object; only twitter, telegram, website, discord are used. |
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. |
There is no streamerAddress, and a launch cannot nominate one. Who
earns a token’s streamer share is decided by the platform when the request
arrives: if a Candle streamer is live at that moment the token is theirs
forever, otherwise the key owner keeps it. The decision is read once, before
any chain work, so a stream that ends mid-launch does not change it, and it
is frozen on chain at registration. A body still carrying the old field is
not rejected; the value is simply not part of the request any more, and it
does not affect the idempotency hash either.
The dev buy, the network fee, and every other cost of the launch are all
paid from the key owner’s own delegated 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 delegated 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 tier’s: 10 on Free and Believer, 30 on Pro, 60 on Max)
is the Candle-hosted image pipeline (Phase 2, R15): 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.
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.
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, "resolved": { "chain": "solana", "quoteAsset": "sol", "mode": "open", "stakerAllocationBps": 50, "dexVersion": null, "visibility": "production", "buyAmount": "500000000" }, "checks": { "image": "ok", "exclusiveEligible": true }, "matrixVersion": 1, "size": { "txBytes": 1218, "limit": 1232, "maxNameBytes": 28, "fits": true }}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.
Async launches and jobs
Section titled “Async launches and jobs”Two additions (Phase 2, R3) 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": "...", "devBuy": { "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.
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": { "signature": "..." }}| 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 | Present only when buyAmount was non-zero and the dev buy landed. On Hood the dev buy is best-effort (see Hood notes); its absence does not mean the launch failed. |
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.
| 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 |
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 | buyAmount is not a valid non-negative integer (number or decimal string). | no |
DEV_BUY_TOO_HIGH | 400 | buyAmount exceeds the per-launch dev-buy ceiling (DEV_BUY_MAX_LAMPORTS/DEV_BUY_MAX_WEI, 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 |
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 |
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, tier floor): 30/min on Free and Believer, 300 on Pro, 600 on Max (see Caps and trust tiers 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 |
TIER_REQUIRED | 403 | Importing or linking a wallet requires Pro or Max tier (linked wallets endpoints only). | 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. 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. Place a buy manually via
nextBuyif it did not land. The buy’sminAmountOutis derived from a simulated quote (2% tolerance) rather than sent as0, since the owner’s wallet address and the buy’s size/timing are all known in advance and an unprotected buy could otherwise be sandwiched. - 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 (see Operational notes), since the factory’s trust model depends on the clone being backend-vouched. The key owner — or, when a Candle streamer was live at request time, that streamer — is still the fee recipient the clone pays out to; only who deploys it is platform-side. 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”GET /api/v1/markets/:chain/:mint (chain: solana or hood) returns one
chain-agnostic shape regardless of whether the mint is a Solana bonding
curve or a Hood CandleCurve, cached ~5s server-side:
{ "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 (404) if no market matches.
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 the partner changelog for the new contract addresses.
The response also carries a migration block (Phase 2, R23):
"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), or delayed (the Hood keeper exhausted its retries,
gaveUpAt set, a human has been alerted). 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.
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 closed while graduating; sells remain open”).
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|bluechip&chain=solana|hood
is a thin, public read onto the same feed buckets the trade page tabs use,
optionally filtered to one chain, cached ~10s server-side. 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 > believer > free, see Caps and trust
tiers 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 /dev/agent dashboard’s tier
strip renders, and the SDK’s CandleClient.getAgentTier() wraps it directly:
{ "success": true, "tier": "pro", "liveTier": "pro", "stakedCndl": 600000, "heldCndl": 0, "thresholds": { "minStakedCndl": 500000, "minHeldCndl": 1000000, "graceMs": 172800000 }, "grace": { "active": false, "startedAt": null }, "maxTierExpiresAt": null, "feeBps": 25, "feeTotals": [{ "chain": "solana", "quoteAsset": "sol", "feeRawSum": "9007199254740993", "count": 3 }]}tier and liveTier resolve exactly per the Caps and trust tiers table above (display
tier is max > pro > believer > free; liveTier never reports believer, since
that label lives only on the display tier). 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 (Phase 2, R9) so an agent stops polling for its own launches’ outcomes. Tenant-scoped: deliveries go only to endpoints registered by the account that launched the token; there is no global firehose.
Registering
Section titled “Registering”Managed from the same authed session surface as API keys, requires agent features enabled:
POST /api/v1/agent/webhookswith{ "url": "https://...", "events": [ ... ] }. Returns 201 with the endpoint plus its signingsecret(whsec_...), shown only in this response. The URL must be public https; private/loopback targets are rejected at registration AND re-checked at every delivery.GET /api/v1/agent/webhookslists endpoints (revoked included),secretPrefixonly.DELETE /api/v1/agent/webhooks/:idrevokes one.GET /api/v1/agent/webhooks/:id/deliveriesreturns the last 20 delivery attempts (status, attempts, last status code/error) for self-serve debugging.- At most 3 active endpoints per account (
WEBHOOK_LIMIT_REACHEDpast the cap).
Events
Section titled “Events”| Event | When | Payload core |
|---|---|---|
launch.confirmed | A headless launch confirmed | clientLaunchId, chain, mint, pool, signature, devBuy? |
launch.failed | A launch failed definitively after acceptance (executor failures; synchronous 4xx gate rejections are not events) | clientLaunchId, chain, errorCode |
curve.graduated | Graduation observed (Solana watcher / Hood keeper first sighting) | chain, mint, curve |
migration.completed | complete flipped true (venue seeded), both chains | chain, mint, pool, migratedAt |
migration.delayed | The Hood keeper exhausted retries and paged a human | chain, mint, curve, attempts, lastError? |
trade.executed | An agent trade recorded: a main payer’s inline execution, or a linked payer’s verified /confirm (see docs/agent-trading.md) | clientTradeId, chain, side, mint, quoteAsset, amountRaw, expectedOutRaw, signature, fee: { bps, feeRaw } |
order.triggered | A limit order’s price condition held and the keeper flipped it open to triggered. Moves no money: the agent still completes the trade itself, so this replaces polling GET /orders?status=triggered | clientOrderId, chain, mint, side, amountRaw, targetPrice, price, expiresAt |
Delivery format and signature
Section titled “Delivery format and signature”Each delivery POSTs { "id": "evt_...", "event": "...", "createdAt": <ms>, "data": { ... } }
with headers x-candle-event, x-candle-delivery (the unique event id; dedupe on it), and
x-candle-signature: t=<unix seconds>,v1=<hex hmac-sha256(secret, "<t>.<raw body>")>Verify by recomputing the HMAC over "<t>.<raw body>" with your endpoint’s secret,
comparing constant-time, and rejecting timestamps outside your tolerance (300s is a sane
default). The SDK ships verifyWebhookSignature(secret, header, body, nowSec) implementing
exactly this. Respond 2xx quickly; anything else counts as a failed attempt.
Retries
Section titled “Retries”Failed deliveries retry on a backoff of 1m, 5m, 15m, 30m, 1h, 2h, then 4h twice; after 8
failed attempts the delivery is dead and will not be retried (about 12 hours end to end).
Deliveries are driven by a 30-second sweep, so a first attempt lands within ~30s of the
event. Delivery order is not guaranteed; dedupe on id and treat createdAt as the event
time, not the delivery time.
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:
buildCreateTokenAndPoolTxInApp(apps/api/src/services/candle/build-create-pool.ts) prepends aSystemProgram.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. -
LAUNCH_BOT_WALLET_KEY(Solana) andHOOD_LAUNCH_WALLET_KEY(Hood) are no longer spent by any launch, headless or otherwise —apps/api/src/services/candle/no-platform-funding.test.tspins this by asserting neither symbol appears in either headless executor. They no longer need to be set either: the boot check was narrowed after it crashlooped staging (HEADLESS_LAUNCH_ENABLEDtrue with the Hood key unset), sovalidateSecretsAtStartup()now requires onlyCONVEX_INTERNAL_KEY. Leave both unset; a zero balance is expected, not an incident. -
The one wallet operators do still need to fund for Hood is the Believer NFT relayer (
HOOD_BELIEVER_SIGNER_KEY, documented indocs/runbooks/hood-deploy.md). It signs every per-launch fee-splitter clone deploy (see Hood notes above), platform infrastructure that still runs ahead of the owner’s own curve-creation transaction — an underfunded relayer fails every Hood launch at that step, regardless of how well-funded the launching account’s own wallet is. -
Disable headless launches instantly by setting
HEADLESS_LAUNCH_ENABLED=false.
Smoke tests
Section titled “Smoke tests”Two manual smoke scripts exercise the real endpoint end to end, print both responses, and prove idempotency by POSTing the same body twice:
apps/api/scripts/headless-launch-smoke.ts: Solana.apps/api/scripts/hood-headless-smoke.ts: Hood. Run against local Hood dev (docs/runbooks/local-hood-dev.md) first.
API_URL=http://localhost:3001 \AGENT_API_KEY=cndl_test_... \IMAGE_URL=https://example.com/logo.png \SMOKE_RUN_ID=local-1 \bun run apps/api/scripts/headless-launch-smoke.tsNeither script has an automated-test equivalent; they hit a real server by
design. AGENT_API_KEY must belong to an account that has delegated agent
access (see Agent access above) for the chain under test, with its own
delegated wallet funded to cover the launch — there is no service wallet
behind these anymore, and both scripts’ header comments now say exactly
that. Pass --async to either script to exercise the Phase 2 async mode
instead: a 202 up front, then a poll of the jobs endpoint until the ledger
row is terminal.
packages/mcp wraps this API (launch, dry-run, market state, feed,
activity reporting, agent profile, trading, launch-and-seed) as seven tools over stdio for
MCP-capable agent clients. See
packages/mcp/README.md for tool definitions,
environment variables, and client config.