Skip to content

Headless launch API

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

v2 replaces the old shared LAUNCH_BOT_API_KEY with self-serve, per-account agent keys, adds dual-chain support, idempotent retries, and a dry-run mode. 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.

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

Getting a working key takes three steps, in order:

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

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

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

  1. POST /api/v1/agent/enable (authed, requires a linked Solana wallet) with { "enabled": true } to turn on agent features for the account. This step alone does not require delegation; delegation is checked next, when a key is actually issued.
  2. POST /api/v1/agent/keys (authed) issues a key, but only once the account has delegated agent access for at least one chain (see Agent access above); otherwise AGENT_WALLET_NOT_DELEGATED (403). The plaintext key is returned only in this response; store it now, it cannot be recovered later. The response (and every row GET /api/v1/agent/keys returns) carries launchChains: the chains ("solana" and/or "hood") delegated at issuance. It is a display snapshot only, not the authority checked at launch time — delegation can be revoked on Privy’s side at any moment, so every launch re-resolves it live rather than trusting this field.
FieldNotes
environmentproduction (default) or test. See below for the test restriction.
labelOptional display name, 1 to 64 characters after trimming. Renameable later via PATCH. With none, the dashboard derives a name from the key’s capabilities.
expiresInDaysOptional integer, 1 to 365. Converted to an absolute expiresAt at creation. Mutually exclusive with expiresAt.
expiresAtOptional absolute epoch ms, strictly in the future. Fixed at creation: there is no renewal call and no PATCH field for it. Omit both fields for a key that never expires (the default). Once past, every call with that key returns KEY_EXPIRED (401).
txLimitOptional { "usdMicros": <positive integer>, "reset": "never" | "daily" | "weekly" | "monthly" }. Caps this key’s own cumulative USD trading volume over that window; exceeding it is KEY_LIMIT_REACHED (403) at trade build.
refOptional 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).
scopesSubset 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 key
x-api-key: cndl_test_... # test key

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

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

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

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

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

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

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:

TierGateRequests/min per keyLaunches/day per accountImage uploads/min (per key)Linked wallets (max active)Spend limits
Freeagent features enabled305100per-key, default none
BelieverBeliever NFT at key issuance3020100per-key, default none
Pro500k CNDL staked or 1M CNDL held (live, 48h grace)300503010per-key, default none
Maxcontact us6001,000601,000per-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:

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

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.

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

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

Pro- and Max-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).

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

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

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:

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

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.

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.

Terminal window
PRIVY_APP_ID=... \
PRIVY_APP_SECRET=... \
PRIVY_AUTHORIZATION_PRIVATE_KEY=... \
bun run scripts/verify-privy-policies.mjs

With 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.

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.

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

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

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

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.

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

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

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

    { "success": true, "transaction": { "to": "0x...", "data": "0x..." }, "curveAddress": "0x...", "clientLaunchId": "...", "expiresAt": 1754401800000 }
  2. The agent signs, through the Candle sign relay. The wallet’s Privy signer quorum (the 1-of-1 quorum registered at import time, see Import flow above) needs an app-authenticated call to Privy that an external agent cannot make alone: Privy requires both the agent’s own P-256 authorization signature over the exact transaction AND app-level auth that only Candle’s server secret satisfies. The agent computes its authorization signature locally with its own key (the SDK’s signLinkedTransaction() does this via buildPrivyAuthorizationSignature, packages/sdk/src/authorization-signature.ts), which Candle never sees, and calls POST /api/v1/agent/wallets/:id/sign (swap:write scope). Candle authenticates the app with its own server secret and forwards that exact, already-authorized request to Privy unchanged: it cannot substitute a different wallet or transaction, since the agent’s own signature covers both, and it never holds the signer key. Candle’s server IS a required participant in this call, though: an agent cannot reach Privy’s wallet-sign RPC directly, since Privy’s app-level gate only accepts Candle’s own server secret. This is still no custody of the signer — Candle relays an already-authorized request, it never generates the signature or receives the P-256 key. Requires privyAppId (a PUBLIC Privy identifier, the same value the app exposes as NEXT_PUBLIC_PRIVY_APP_ID, not a secret) configured on the client, matching the app id the relay’s server authenticates under.

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

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

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

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.

  • Never signs. The agent authorizes every transaction itself, with its own P-256 key; Candle’s relay only forwards the already-authorized request to Privy and cannot alter the wallet or the transaction the authorization covers. /build returns an unsigned payload; /confirm only ever verifies what already landed on-chain. (Candle’s server IS a required participant in the signing HTTP call, since Privy also needs app-level auth an external agent cannot supply alone; see “The agent signs” in Flow above. “Never signs” describes who authorizes the transaction, not whether Candle’s infrastructure is involved in the call.)
  • Never holds the key. The linked wallet’s signer was registered at import time from a caller-generated P-256 keypair whose private half never left the caller (see The never-plaintext rule above); nothing about this path changes that. The SDK’s SecretStore (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.

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

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

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

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

Hood. The base token address is only known from the canonical factory’s own CurveCreated event, discovered when /confirm verifies the broadcast transaction, not before — so /build cannot insert a hidden token row the way Solana’s does; there is no mint yet to key it by. Instead /build stores the predicted CREATE2 curve address as pool and defers the token-row insert entirely to /confirm. Everything /confirm needs, both to verify that curve on-chain and to then insert the token row for the first time (metadata, the platform-deployed splitter clone, the CREATE2 salt, the resolved matrix cell), is stamped onto the same ledger row 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.

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

  • Lazy 30-minute expiry. A build that never gets confirmed is not swept by any background job or cron. Instead, every /build call first sweeps the CALLING account’s own stale builds: any submitted, self-signed row whose build is more than 30 minutes old is failed (SELF_BUILD_EXPIRED) and its daily-cap slot refunded, before the new build’s own gates even run. There is no cross-account sweep; an account that never calls /build again simply keeps a stale row until the next UTC day’s counter reset makes it moot regardless.
  • Same-clientLaunchId retry. Calling /build again with the same clientLaunchId and the same body (including the same linkedWalletId, which is part of what gets hashed for idempotency) replays the same attempt: if it is still submitted and unexpired, /build rebuilds and returns a fresh unsigned transaction for the same mint/pool; if it is already confirmed, /confirm returns the stored success outright, without touching the chain or Convex again; if it already failed or expired, the next /build call starts over cleanly. A DIFFERENT body (including a different linkedWalletId) against the same clientLaunchId is IDEMPOTENCY_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 /build assembles, not a separate transaction the linked wallet’s own Privy policy ever gets to inspect on its own terms — and even if it did, that policy’s own platform-ceiling DENY only ever evaluates the outer transaction’s own shape, never what a program instruction moves internally (live-verified, see “What a cap actually bounds” above). This API-side check at /build is therefore the ONLY enforcement of the key’s cap against a Solana self-signed dev buy.
  • Hood: the declared-vs-sent gap this used to describe is now CLOSED for a linked wallet, not widened. A linked wallet can only ever sign through the build-bound relay (see “The relay signs only API-built transactions” in 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 /buildcreateCurve is non-payable, so /build never assembles it at all — which means the relay now refuses it outright with UNRECOGNIZED_TRANSACTION, before any Privy call, regardless of what buyAmount was declared or what the agent tries to send. The self-launch itself (createCurve, plus the optional fee leg) is unaffected, since Candle builds and stamps both of those. Net effect: a linked wallet’s out-of-band Hood dev buy can no longer be SIGNED at all through Candle, which is stricter than the old per-account eth DENY rule ever was, not looser.
  • The delegated (main-wallet) Hood dev buy is a different path, unaffected by any of this. A /launch/headless dev buy on the account’s own delegated wallet signs server-side through Candle’s own owner-signer (see docs/headless-launch.md’s Request table, DEV_BUY_MAX_WEI) and is bounded by that per-pair ceiling plus the server-side main-scope checkSpendAgainstLimits gate — 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.

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.

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.

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.

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.

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

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

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

    Each payer is { "type": "main" } (the account’s own 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, and unsignedTxBase64 is omitted entirely. A "client" signer (a "linked" payer) carries unsignedTxBase64 for the agent to sign itself.

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

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

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

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

    • 200, landed:
      {
      "status": "landed",
      "bundleId": "b7e2b4b0-6b1a-4e0e-9c2a-7b6f0e2f9e21",
      "mint": "8t4y...Mint",
      "signatures": ["<launch sig>", "<buy 1 sig>", "<buy 2 sig>"]
      }
      signatures[0] is the launch transaction; signatures[1..] are the buy legs, in the same order /build returned them. Every leg’s own fee, amount, and fill are already exactly what /build recorded — a Jito bundle is all-or-nothing, so nothing here needs the balance-delta verification a broadcast /confirm elsewhere in this API has to do.
    • 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 }
      retryable is always false for "failed". For "timeout" it is true only when Candle could prove the bundle’s shared blockhash expired with no confirmation ever observed; false means the resolution window simply ran out with no definitive answer — the bundle might still land, so wait rather than immediately relaunch under a fresh clientLaunchId. Either way, bundleId itself is dead the moment this response arrives (see “bundleId lifetime” below) — recovery is always a fresh /build call, never a retried /submit with 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.

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

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

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

Every buy leg is priced and gated exactly like an ordinary /trade/agent/build buy (same platform fee, same referral discount, same SPEND_LIMIT_EXCEEDED check against the key’s own spend limits — see Spend limits above and the Fee model in 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.

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

Every error code this route can return is one already documented elsewhere in this file or in 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):

Terminal window
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": [...] }
Terminal window
curl -X POST "$API_URL/api/v1/launch/headless" \
-H "x-api-key: $AGENT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"clientLaunchId": "my-bot-run-42",
"chain": "solana",
"quoteAsset": "sol",
"mode": "open",
"stakerAllocationBps": 50,
"buyAmount": 1000000,
"name": "Trend Coin",
"symbol": "TREND",
"imageUrl": "https://example.com/logo.png",
"description": "Launched by the trend bot",
"socials": { "twitter": "https://x.com/...", "website": "https://..." },
"visibility": "production"
}'
Terminal window
curl -X POST "$API_URL/api/v1/launch/headless" \
-H "x-api-key: $AGENT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"clientLaunchId": "my-bot-run-43",
"chain": "hood",
"quoteAsset": "eth",
"dexVersion": "v4",
"mode": "open",
"buyAmount": "1000000000000000",
"name": "Trend Coin",
"symbol": "TREND",
"imageUrl": "https://example.com/logo.png",
"visibility": "production"
}'
FieldRequiredNotes
clientLaunchIdyesIdempotency key, unique per account. 1-128 chars. See Idempotency below.
chainnosolana (default) or hood.
quoteAssetnoDefaults to the chain’s default (sol on Solana, eth on Hood). Solana: sol, usdc, cndl. Hood: eth, usdg.
modenoopen (default) or exclusive. v2 changed this default from exclusive to open; pass mode: "exclusive" explicitly if that is what you want. Exclusive requires Believer NFT eligibility, see below.
stakerAllocationBpsnoInteger, 50-1000 (0.5%-10%). Defaults to 50.
dexVersionHood onlyv3 or v4, required for chain: "hood". Rejected (VALIDATION_FAILED) if set for Solana.
buyAmountnoInitial dev buy, in the QUOTE asset’s raw units. 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, symbolyesUTF-8 byte limits: name 32, symbol 10 (Metaplex/on-chain metadata limits).
imageUrlyeshttps URL. Server fetches, validates (JPEG/PNG/GIF/WebP, <10MB, roughly SQUARE: aspect ratio at most 1.5:1, else IMAGE_WRONG_SHAPE), re-hosts. The image renders as a small circle/square avatar everywhere, so share cards, OG images and banners do not belong here: pass those as bannerUrl instead. Must resolve to a public address (no private/loopback hosts) and must not redirect. Agents without their own image host should upload the bytes first via the hosted image pipeline below and use the returned URL.
bannerUrlnohttps URL to WIDE artwork for the token page’s banner strip, a full-width band above the token’s details. Same fetch, type, size and SSRF rules as imageUrl, and re-hosted the same way, but the opposite shape gate: it must be wider than 1.5:1 (e.g. 1200x630), else BANNER_WRONG_SHAPE. The two gates partition every image, so any given file is valid for exactly one of the two fields. null reads as omitted. Omit it and the strip falls back to imageUrl, which is what every launch got before this field existed.
descriptionno
socialsnoObject; only twitter, telegram, website, discord are used.
visibilitynoproduction (default), test, local, hidden. Test keys are restricted, see above.
asyncnoBoolean, default false. When true the server returns 202 after the pre-flight gates and finishes in the background; see Async launches and jobs. Excluded from the idempotency hash, so async and blocking retries of the same clientLaunchId never conflict.

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.

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:

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

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

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

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

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

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

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

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

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

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

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.

{
"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": "..." }
}
FieldNotes
poolnull when the launch has no pool address yet (e.g. an in-flight replay).
matrixVersionThe launch economics matrix version this launch resolved against. Echoed so integrators can tell which curve/fee terms applied.
links.explorerAlways present on Solana (Solscan). On Hood, present only when HOOD_EXPLORER_URL is configured server-side; omitted otherwise.
nextBuy.marketThe pool address, or the mint when there is no pool yet. Where an agent places its next buy.
nextBuy.marketStateUrlRelative path; prefix with your API_URL. See Market state below.
devBuyPresent 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.

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.

CodeStatusMeaningTypically retryable
VALIDATION_FAILED400Generic field validation failure (see field).no
INVALID_NAME_BYTES400name exceeds 32 UTF-8 bytes.no
INVALID_SYMBOL400symbol exceeds 10 UTF-8 bytes.no
IMAGE_TOO_LARGE400imageUrl resolves to a file over 10MB.no
IMAGE_UNREACHABLE400imageUrl did not resolve or respond in time.no
IMAGE_REDIRECT_NOT_ALLOWED400imageUrl issued a redirect; not followed.no
DEV_BUY_UNSUPPORTED_FOR_PAIR400Non-zero buyAmount against a quote asset that does not support a dev buy.no
DEV_BUY_TOO_LOW400buyAmount is not a valid non-negative integer (number or decimal string).no
DEV_BUY_TOO_HIGH400buyAmount 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_UNKNOWN400quoteAsset is not a known pair for chain.no
EXCLUSIVE_NOT_ELIGIBLE403mode: "exclusive" requested but the account is not Believer-NFT eligible.no
UNAUTHORIZED401Missing, malformed, unknown, or revoked API key.no
SCOPE_MISSING403The key lacks the scope this route requires.no
AGENT_WALLET_NOT_EMBEDDED403The account has no Privy embedded wallet for the requested chain — a self-connected wallet (Phantom, MetaMask) can never be delegated. Not retryable with the same key; the account needs to sign up via Google, X, or email to get one, then delegate it.no
AGENT_WALLET_NOT_DELEGATED403An embedded wallet exists for the requested chain but agent access was never approved for it. Returned by POST /api/v1/agent/keys at issuance, and can also surface at launch time if the owner had delegated at issuance and later un-delegated without Privy reporting it as a revocation.yes, after delegating
DELEGATION_REVOKED403Delegation existed and was approved, but Privy rejects the signing/sending call now — revoked mid-flight, or any time between key issuance and this launch. The key itself stays valid; only delegation needs to be re-approved.yes, after re-delegating
INSUFFICIENT_OWNER_BALANCE402The owner’s own wallet — the only wallet that ever pays for a headless launch — cannot cover fee + rent + dev buy. Caught at simulation, before broadcast.yes, once funded
TRANSACTION_TOO_LARGE400The assembled launch transaction exceeds Solana’s 1232-byte packet limit; realistically a long name+symbol combined with a bundled dev buy. Caught at build time, before signing (also caught early by /dry-run, see above). The response’s error.maxNameBytes names the largest name (UTF-8 bytes) that would fit THIS exact request (same symbol, dev buy, and fee leg); error.txBytes/error.overBy give the raw byte count and how far over the limit it landed. Shorten the name to maxNameBytes or fewer bytes, shorten the ticker, or launch without the dev buy.no, change the request
MARKET_NOT_FOUND404No market for that chain/mint pair (market-state endpoint only).no
IDEMPOTENCY_CONFLICT409Same clientLaunchId reused with a different body (not retryable), or the launch is still in flight (retryable).sometimes, see Idempotency
RATE_LIMITED429The key’s effective per-minute rate limit was exceeded. That ceiling is max(key row, 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_REACHED429Account’s daily launch cap reached.yes, next day
LAUNCH_FAILED500Build, broadcast, or confirmation failed (includes confirmation timeout, see Idempotency).yes, same clientLaunchId
LAUNCH_DISABLED503HEADLESS_LAUNCH_ENABLED is not "true" server-side.no
TIER_REQUIRED403Importing or linking a wallet requires Pro or Max tier (linked wallets endpoints only).no
WALLET_LIMIT_REACHED400The account already has the maximum active linked wallets for its tier; revoke one first.yes, after revoking one
KEY_EXPIRED401The key’s own expiresAt has passed. Checked after the secret is verified, so a bare prefix cannot probe expiry. Expiration is fixed at creation: recovery is always a new key.no
KEY_LIMIT_REACHED403The key’s own txLimit (windowed USD trading volume) would be exceeded by this request. Carries resetsAt (epoch ms, or null for a lifetime cap).yes when resetsAt is non-null, after the window rolls
SPEND_LIMIT_EXCEEDED400A buy’s amountRaw + feeRaw exceeds the calling key’s spend cap for that asset. Carries spendLimit.limitSource (always key). See Spend limits above.no, as the same amount
TEST_ENVIRONMENT_FORBIDDEN403A test-environment key called a rail that has no non-production mode (the trade rail, atomic launches).no
BUILD_TIMEOUT504A build step exceeded its deadline (quote, simulation, or bundle assembly).yes
UNRECOGNIZED_TRANSACTION403The sign relay found no matching build-time claim for the submitted transaction: hand-rolled, already signed once, or the claim expired.no, rebuild first
SPEND_POLICY_DENIED403The owner wallet’s own Privy policy rejected the transaction.no
AGENT_WALLET_MISSING400Main payer: the account has no embedded wallet for that chain.no
SWAP_FAILED500An unclassified failure executing or recording a swap leg. Message states whether the transaction may already be on-chain.sometimes, see the message
JOB_NOT_FOUND404The clientLaunchId/clientTradeId does not resolve to a live row: never built, or swept after expiry.no, rebuild
KEY_ISSUANCE_FAILED503A dependency needed to issue the key was unreachable (Convex, Privy, or the on-chain Believer check).yes
IMAGE_WRONG_SHAPE400The uploaded image is not square, or violates the size bounds.no, change the file
UPLOAD_INVALID400The upload body was malformed or the content type is unsupported.no
WEBHOOK_LIMIT_REACHED400The account already has the maximum registered webhook endpoints.yes, after deleting one
WALLET_ALREADY_LINKED409This address already has an active linked-wallet row, on this account or another one.no
LOOSEN_REQUIRES_SESSION403An agent key tried to loosen a boundary only a Privy session may loosen: raising or clearing a key’s spend cap (PUT /api/v1/agent/keys/:prefix/limits), or adding a withdrawal address.yes, with a Privy session

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

  • The on-chain creator is the key owner’s own wallet. CandleCurveFactory.createCurve hard-reverts CreatorMismatch unless the signer equals params.creator; since the owner’s wallet is now the one signing and sending (via Privy), setting creator to that same address satisfies this structurally, with no workaround needed. Gas, the dev buy, and every other cost come from that same wallet.
  • Exclusive Hood launches check the SAME wallet on-chain and off. The on-chain NFT gate checks creator at construction and msg.sender on every buy(), and both now resolve to the owner’s wallet — the exact wallet this API’s own eligibility check (whether the account may request mode: "exclusive" at all) already tests. The two checks finally agree; the only way they can still disagree is the owner’s NFT holding changing in the gap between the two checks (a transfer — Believer NFTs have no burn path today, but the on-chain gate still checks defensively).
  • Dev-buy tokens land in the owner’s own wallet, the same wallet that paid for them.
  • The dev buy is best-effort and slippage-protected. The curve is already deployed and recorded in Convex before the dev buy runs; a dev-buy failure is logged and does not fail or unwind the launch. Place a buy manually via nextBuy if it did not land. The buy’s minAmountOut is derived from a simulated quote (2% tolerance) rather than sent as 0, 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 CandleFeeSplitter clone (fee recipient, independent of creator) is deployed by the same backend relayer key that mints Believer NFTs, not the owner’s wallet — this one piece stays platform infrastructure (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.
  • dexVersion is required (v3 or v4); it selects which migrator the curve graduates through.
  • Quote assets: eth (native, the only one that supports a dev buy) and usdg.

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

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

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

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 is the creator-supplied name, symbol, description, image, and socials, or null when we hold none. null is distinct from a creator having supplied an empty description.

onChain says who is authoritative:

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

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

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

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

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

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

The contract cutover landed at block 30442973. Hood curves created from that block onward report onChain: true; anything earlier reports onChain: false permanently, since those contracts have no metadata fields and cannot be retrofitted. Branch on the block, not on a date. See 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”).

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

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

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

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

GET /api/v1/markets/feed?bucket=new|graduated|onfire|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).

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

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.

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.

Managed from the same authed session surface as API keys, requires agent features enabled:

  • POST /api/v1/agent/webhooks with { "url": "https://...", "events": [ ... ] }. Returns 201 with the endpoint plus its signing secret (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/webhooks lists endpoints (revoked included), secretPrefix only.
  • DELETE /api/v1/agent/webhooks/:id revokes one.
  • GET /api/v1/agent/webhooks/:id/deliveries returns the last 20 delivery attempts (status, attempts, last status code/error) for self-serve debugging.
  • At most 3 active endpoints per account (WEBHOOK_LIMIT_REACHED past the cap).
EventWhenPayload core
launch.confirmedA headless launch confirmedclientLaunchId, chain, mint, pool, signature, devBuy?
launch.failedA launch failed definitively after acceptance (executor failures; synchronous 4xx gate rejections are not events)clientLaunchId, chain, errorCode
curve.graduatedGraduation observed (Solana watcher / Hood keeper first sighting)chain, mint, curve
migration.completedcomplete flipped true (venue seeded), both chainschain, mint, pool, migratedAt
migration.delayedThe Hood keeper exhausted retries and paged a humanchain, mint, curve, attempts, lastError?
trade.executedAn 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.triggeredA 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=triggeredclientOrderId, chain, mint, side, amountRaw, targetPrice, price, expiresAt

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.

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.

  • 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 a SystemProgram.transfer from the launcher to the platform keypair for exactly what initialize_pool is about to spend:

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

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

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

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

  • LAUNCH_BOT_WALLET_KEY (Solana) and HOOD_LAUNCH_WALLET_KEY (Hood) are no longer spent by any launch, headless or otherwise — apps/api/src/services/candle/no-platform-funding.test.ts pins 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_ENABLED true with the Hood key unset), so validateSecretsAtStartup() now requires only CONVEX_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 in docs/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.

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.
Terminal window
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.ts

Neither 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.