Comms Terminal
DevelopersREST + WebSocket

The Hatchery API

Spawn tokens, quote and trade, and stream live activity from your own scripts and bots. The server builds unsigned Solana transactions; you sign them locally and send them.

Base URL
https://api.thehatchery.site
WebSocket
wss://api.thehatchery.site/ws
Format
JSON in and out. Amounts are decimal strings in UI units.
Auth
None. Every endpoint is public and rate limited per IP.
Chain
Solana mainnet. Pools are Meteora Dynamic Bonding Curve, then DAMM v2.

The quote token is $BITCOIN on Solana: the quote mint (see GET /config, quote.mint). Fees, prices, reserves and payouts are all in $BITCOIN. Consume trades can be paid in SOL (a Jupiter SOL→$BITCOIN leg inside the same transaction) or in $BITCOIN directly (payWith: 'QUOTE'). The quote mint may be a legacy SPL Token or a Token-2022 mint; the built transactions handle either, so read quote.decimals rather than assuming them.

CORS: browsers only allow REST calls from The Hatchery's own origins. Server-side code, scripts and bots are not affected. A web app on another origin must call the API from its own backend. The WebSocket accepts any origin.

One file that spawns a token (optionally consuming at launch) and consumes an existing one. Node 18+, @solana/web3.js v1, and a funded keypair file from solana-keygen new -o wallet.json. It needs SOL for the launch rent, network fees and the initial Consume trade.

hpospad.ts
// hpospad.ts: spawn a token and trade on The Hatchery from Node 18+.
//   npm i @solana/web3.js@1
//   npx tsx hpospad.ts launch ./logo.png
//   npx tsx hpospad.ts consume <mint> 0.05
import { readFileSync } from 'node:fs'
import { Connection, Keypair, VersionedTransaction } from '@solana/web3.js'

const API = 'https://api.thehatchery.site'
// Any mainnet RPC works. The Hatchery's proxy is HTTP only, so we poll for confirmation.
const connection = new Connection(process.env.RPC_URL ?? `${API}/rpc`, 'confirmed')
// A solana-keygen JSON file. The key stays on this machine; the API never sees it.
const wallet = Keypair.fromSecretKey(
  Uint8Array.from(JSON.parse(readFileSync(process.env.KEYPAIR ?? 'wallet.json', 'utf8'))),
)

type PayWith = 'SOL' | 'QUOTE'
type Quote = { inAmount: string; outAmount: string; minOut: string; feeQuote: string; priceImpactPct: number | null; route: string }
type Built = {
  preTransactions?: string[]
  transaction: string
  lastValidBlockHeight: number
  mint?: string
  mintSigned?: boolean
  quote: Quote | null
}

async function api<T>(path: string, body?: unknown): Promise<T> {
  const res = await fetch(`${API}${path}`, {
    method: body === undefined ? 'GET' : 'POST',
    headers: body === undefined ? undefined : { 'content-type': 'application/json' },
    body: body === undefined ? undefined : JSON.stringify(body),
  })
  const json: unknown = await res.json()
  if (!res.ok) {
    const e = json as { error?: string; details?: unknown }
    throw new Error(`${res.status} ${e.error ?? 'error'} ${e.details ? JSON.stringify(e.details) : ''}`)
  }
  return json as T
}

async function confirm(signature: string, timeoutMs = 90_000): Promise<void> {
  const started = Date.now()
  while (Date.now() - started < timeoutMs) {
    const { value } = await connection.getSignatureStatuses([signature])
    const st = value[0]
    if (st?.err) throw new Error(`${signature} failed: ${JSON.stringify(st.err)}`)
    if (st?.confirmationStatus === 'confirmed' || st?.confirmationStatus === 'finalized') return
    await new Promise((r) => setTimeout(r, 1_000))
  }
  throw new Error(`${signature} not confirmed in time; build a fresh transaction and retry`)
}

async function signSendConfirm(b64: string): Promise<string> {
  const tx = VersionedTransaction.deserialize(Buffer.from(b64, 'base64'))
  // Adds the wallet's signature; a server-held mint signature is kept.
  tx.sign([wallet])
  const signature = await connection.sendRawTransaction(tx.serialize(), { maxRetries: 3 })
  await confirm(signature)
  return signature
}

/** preTransactions first, in order, each confirmed; then the main transaction. */
async function sendBuilt(built: Built): Promise<string> {
  for (const pre of built.preTransactions ?? []) console.log('pre-transaction', await signSendConfirm(pre))
  return signSendConfirm(built.transaction)
}

async function launch(imagePath: string): Promise<void> {
  const token = {
    name: 'My Token', // <= 32 bytes
    symbol: 'MYTKN', // <= 10, letters and digits
    description: 'Launched from a script',
    twitter: 'https://x.com/example', // optional, https only; the website is always the token's Hatchery page
  }
  // 1. Pin the image to IPFS. The declared type is ignored; the server reads the bytes.
  const image = `data:image/png;base64,${readFileSync(imagePath).toString('base64')}`
  const { imageUrl } = await api<{ imageUrl: string }>('/launch/metadata', { image })

  // 2. Build. No `mint`: the server assigns a …hpos vanity address, signs for it
  //    and pins the metadata JSON (website = https://thehatchery.site/token/<mint>).
  const built = await api<Built>('/launch/build', {
    creator: wallet.publicKey.toBase58(),
    ...token,
    imageUrl,
    firstBuy: { amount: '0.1', payWith: 'SOL' as PayWith, slippageBps: 500 }, // optional
  })
  if (!built.mint || !built.mintSigned) throw new Error('server did not assign a mint')
  console.log('mint', built.mint, 'initial consume', built.quote)

  // 3. Sign with the creator wallet only, send, confirm.
  const signature = await sendBuilt(built)
  console.log('spawned', signature)
  console.log(`https://thehatchery.site/token/${built.mint}`)
}

async function consume(mint: string, amountSol: string): Promise<void> {
  const params = { mint, side: 'BUY', amount: amountSol, payWith: 'SOL', slippageBps: '300' }
  const quote = await api<Quote>(`/trade/quote?${new URLSearchParams(params)}`)
  console.log('quote', quote)
  const built = await api<Built>('/trade/build', { ...params, slippageBps: 300, owner: wallet.publicKey.toBase58() })
  console.log('consumed', await sendBuilt(built))
}

const [cmd, a, b] = process.argv.slice(2)
const run = cmd === 'launch' && a ? launch(a) : cmd === 'consume' && a && b ? consume(a, b) : null
if (!run) console.log('usage: launch <image> | consume <mint> <sol>')
run?.catch((err: unknown) => {
  console.error(err instanceof Error ? err.message : err)
  process.exit(1)
})

The token page appears at https://thehatchery.site/token/<mint> a few seconds after the launch confirms, once the indexer has picked it up. Read-only calls need nothing but HTTP:

shell
# Live launchpad parameters (fees, quote mint, graduation threshold)
# Read these at runtime; never hardcode the quote mint or fee split.
curl -s https://api.thehatchery.site/config

# Top 10 tokens still on the curve, by 24h volume
curl -s 'https://api.thehatchery.site/tokens?sort=volume&status=CURVE&limit=10'

# One token, its last 20 trades and 1h candles
curl -s https://api.thehatchery.site/tokens/<mint>
curl -s 'https://api.thehatchery.site/tokens/<mint>/trades?limit=20'
curl -s 'https://api.thehatchery.site/tokens/<mint>/candles?tf=1h&limit=200'

# Quote a 0.05 SOL Consume trade (read-only, nothing is built or signed)
curl -s 'https://api.thehatchery.site/trade/quote?mint=<mint>&side=BUY&amount=0.05&payWith=SOL&slippageBps=300'

1. Upload the image

POST /launch/metadata with image, a base64 data: URL (png, jpg, gif or webp, ≤4 MB decoded), pins it to IPFS and returns { imageUrl }. The format is detected from the file bytes; the declared MIME type is ignored.

2. Build the spawn transaction

POST /launch/build with creator (your wallet), name, symbol, the imageUrl from step 1, optional description, twitter and telegram, and optionally firstBuy: { amount, payWith: 'SOL' | 'QUOTE', slippageBps }. The server picks the address, then pins the Metaplex JSON with https://thehatchery.site/token/<mint> as its website; creators don't set the website. The metadata is immutable once the token is spawned. The initial Consume trade lands atomically with pool creation, so nobody can consume before the creator.

  • name: ≤32 bytes UTF-8. symbol: ≤10 bytes, letters and digits; a leading $ is stripped and it is upper-cased.
  • description ≤1000 chars. twitter, telegram: https:// links, ≤200 chars, empty string means none.

Omit mint: the server assigns a vanity address ending in hpos from its pool (a random address if the pool is empty), signs for it, and returns mintSigned: true. You sign only with the creator wallet. Each vanity address is handed out once; the same creator retrying within 10 minutes gets the same address back.

response
{ "transaction": "<base64 v0 tx>", "lastValidBlockHeight": 429320148,
  "mint": "…hpos", "mintSigned": true,
  "quote": { "inAmount": "0.1", "outAmount": "…", "minOut": "…", "feeQuote": "…",
             "priceImpactPct": 0, "route": "SOL > $BITCOIN (Jupiter) > Meteora DBC" },
  "preTransactions": ["<base64 v0 tx>"] }

To use your own mint (for example a vanity address you ground yourself), pass its public key as mint. The response has mintSigned: false and you must sign with both keypairs. The build fails with 409 if that address already exists on chain.

own mint
// Bring your own mint (e.g. your own vanity grind). You must co-sign with it.
const mintKeypair = Keypair.generate()
const built = await api<Built>('/launch/build', {
  creator: wallet.publicKey.toBase58(),
  mint: mintKeypair.publicKey.toBase58(),
  ...token,
  imageUrl,
})
// built.mintSigned === false
const tx = VersionedTransaction.deserialize(Buffer.from(built.transaction, 'base64'))
tx.sign([mintKeypair, wallet])

3. Sign, send, confirm

Deserialize with VersionedTransaction.deserialize, sign with your wallet, send, and confirm. If the response has preTransactions, sign, send and confirm each one first, in order, and only then send transaction. This happens when a SOL leg does not fit in one transaction: the SOL→$BITCOIN swap lands first, then the spawn + initial Consume trade spends exactly what the swap guaranteed. The main transaction cannot be simulated before its pre-transaction lands, so the server only size-checks it.

If the main transaction expires (past lastValidBlockHeight) while you wait, call the build endpoint again for a fresh one. Do not re-send a pre-transaction that already confirmed.

GET /trade/quote (query string) and POST /trade/build (JSON body, plus owner) take the same fields:

  • side: BUY (Consume) or SELL (Purge). These are the API values; the app uses the branded labels.
  • amount: a decimal string in UI units of what you pay in. To consume, that is SOL or $BITCOIN (per payWith); to purge, it is the token. Digits beyond the asset's decimals are truncated (SOL 9, the quote per quote.decimals, tokens 6).
  • payWith: SOL or QUOTE ($BITCOIN). When you purge, it is what you receive.
  • slippageBps: 10 to 5000, default 300 (3%).

The quote is a TradeQuote: inAmount, outAmount, minOut (after slippage), feeQuote ($BITCOIN), priceImpactPct and a readable route. Sending the built transaction works exactly like a spawn, including preTransactions (for Purge trades paid out in SOL, the curve trade lands first and the $BITCOIN→SOL swap second).

  • Filling the curve. A Consume trade larger than what is left on the curve is built as a partial fill: it consumes exactly the remainder, which completes the curve, and the unused amount stays in your wallet. The route says so and inAmount shows the amount actually spent.
  • Graduating. While status is COMPLETE, quotes and builds return 409 until migration finishes.
  • Graduated. Once status is MIGRATED, trades route entirely through Jupiter (the DAMM v2 pool is indexed there). feeQuote reads 0 in that case, but the pool still charges its trading fee.

Paginated lists return { items, nextCursor }; pass nextCursor back as cursor until it is null. Defaults are in parentheses. Path params (:mint, :address) must be valid base58 Solana addresses.

Config & stats

  • GET/config
    Params: none
    Returns: LaunchpadConfigDTO

    Cluster, DBC config, quote token (mint, symbol, decimals, name, logoUrl), fees in bps (tradeFeeBps, creatorBps, platformBps, protocolBps, reflectionBps), curve.migrationQuoteThreshold, token supply/decimals, treasury, feeClaimer, featuredMints, officialMint. Cached 60 s.

  • GET/stats
    Params: none
    Returns: PlatformStatsDTO

    tokens, graduated (from a curve), trebuchet (Trebuchet launches), volume24hQuote, creatorEarnedTotalQuote, reflectedTotalQuote, quoteUsd.

Tokens

  • GET/tokens
    Params: sort new|volume|mcap|progress|lastTrade (new), status CURVE|COMPLETE|MIGRATED (MIGRATED = graduated from a curve, so Trebuchet launches are left out), stage all|curve|migrating|dex|trebuchet (dex = everything on a DEX), q (name, symbol or exact mint, ≤64), creator, limit ≤100 (30), cursor
    Returns: { items: TokenDTO[], nextCursor }

    sort=progress only lists CURVE tokens unless you pass status.

  • GET/tokens/:mint
    Params: none
    Returns: TokenDTO

    Price, market cap, progress (0..1), status, quoteReserve, 24h stats, holders, feesTotalQuote, creatorEarnedQuote, reflectedTotalQuote, pendingReflectionQuote. 404 if unknown.

  • GET/tokens/:mint/circulating-supply
    Params: none
    Returns: { circulatingSupply: number }

    The mint's on-chain supply (no team allocation, vesting or lock), cached 5 minutes. Use this URL as the circulating supply source in Jupiter's token verification.

  • GET/tokens/:mint/trades
    Params: limit ≤200 (50), cursor
    Returns: Paginated<TradeDTO>

    Newest first. venue is DBC (curve), DAMM_V2 (graduated) or CLMM (a Trebuchet launch's Raydium pool).

  • GET/tokens/:mint/candles
    Params: tf 1m|5m|15m|1h|4h|1d (5m), from, to (unix s), limit ≤1000 (500)
    Returns: { timeframe, items: CandleDTO[] }

    Ascending. OHLC in quote per token; empty list (not 404) for unknown mints.

  • GET/tokens/:mint/holders
    Params: limit ≤200 (50)
    Returns: HolderDTO[]

    share is 0..1 of total supply; label marks the curve, graduated pool, platform and creator.

  • GET/tokens/:mint/reflections
    Params: limit ≤100 (20), cursor
    Returns: Paginated<ReflectionEpochDTO>

    One row per fee claim: claimedQuote, creatorQuote, platformQuote, holdersQuote, distributedQuote, carriedQuote, recipients, claimSignatures. Holder fields are 0 while reflections are off.

  • GET/trades/recent
    Params: limit ≤50 (20)
    Returns: TradeDTO[]

    All tokens, newest first. A plain array, not paginated.

Wallets & claims

  • GET/wallets/:address/earnings
    Params: mint (optional filter)
    Returns: WalletEarningsResponse

    Creator side: creatorTotalQuote and created per token launched. Holder side: totalQuote and per-token tokens. Confirmed payouts only.

  • GET/wallets/:address/reflections
    Params: limit ≤100 (30), cursor
    Returns: Paginated<ReflectionPayoutDTO> + totalQuote

    Every holder reflection payout received, with its transaction signature.

  • GET/wallets/:address/trades
    Params: limit ≤100 (30), cursor
    Returns: Paginated<TradeDTO>
  • GET/wallets/:address/claimable
    Params: none
    Returns: ClaimableDTO

    Holder reflections owed but not sent yet: claimableQuote, accruedQuote, claimedQuote, pending, minClaimQuote. Only relevant when reflections are on.

  • POST/claims/build
    Params: { owner }
    Returns: ClaimBuildResponse

    Early withdrawal of claimable reflections: a vault-signed transaction the owner signs as fee payer. 503 if claims are not enabled.

  • POST/claims/:id/confirm
    Params: { signature }
    Returns: { status }

    LANDED, or 202 PENDING if the transaction is not visible yet.

Spawn

  • POST/launch/metadata
    Params: image
    Returns: { imageUrl }

    Pins the image to IPFS. 20 uploads per hour per IP.

  • POST/launch/build
    Params: creator, name, symbol, imageUrl, mint?, description?, twitter?, telegram?, firstBuy?
    Returns: BuiltTransaction + mint, mintSigned

    Creator is fee payer. Simulated before it is returned.

Trade

  • GET/trade/quote
    Params: mint, side, amount, payWith, slippageBps (query string)
    Returns: TradeQuote

    Read-only. Shares the 60/min transaction-build limit.

  • POST/trade/build
    Params: same as quote + owner (JSON body)
    Returns: BuiltTransaction

    Owner is fee payer. Simulated before it is returned (except split pre-transaction flows).

RPC proxy, WebSocket, health

  • POST/rpc
    Params: JSON-RPC 2.0 request or batch (≤20)
    Returns: Upstream JSON-RPC response

    300 requests/min per IP. Allowlisted methods only (below).

  • WSwss://api.thehatchery.site/ws
    Params: subscribe / unsubscribe / ping
    Returns: WsServerMessage

    See WebSocket below.

  • GET/health
    Params: none
    Returns: { ok, db, redis, indexerLagSeconds }

    503 when the database or cache is down. indexerLagSeconds is how stale indexed data may be.

POST /rpc forwards to a private mainnet RPC, so you can send and confirm without your own provider. It is HTTP only: there are no websocket subscriptions, so confirmTransaction will not work; poll getSignatureStatuses instead (as the quickstart does). Other methods return 403 with a JSON-RPC -32601 error. Allowed methods:

sendTransactionsimulateTransactiongetLatestBlockhashgetSignatureStatusesgetBalancegetAccountInfogetMultipleAccountsgetTokenAccountsByOwnergetTokenAccountBalancegetSlotgetBlockHeightgetEpochInfogetFeeForMessagegetGenesisHashgetVersiongetMinimumBalanceForRentExemptiongetRecentPrioritizationFeesisBlockhashValid
shell
curl -s https://api.thehatchery.site/rpc -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"getSignatureStatuses","params":[["<signature>"]]}'

Connect to wss://api.thehatchery.site/ws and send { op: 'subscribe', topics: [...] }. Up to 50 topics per socket; unknown topics are ignored. Send { op: 'ping' } every 25 s to keep the connection open; the server replies { op: 'pong' } and drops sockets that stop responding.

TopicEvents
tradestrade for every token: { trade: TradeDTO, token } with the token's new price, market cap, progress and status.
tokenstoken:new and token:update, each with a full TokenDTO.
token:<mint>That token's trade, token:update and, when holder reflections are on, reflection ({ mint, epochId, distributedQuote, recipients }).

Messages from the server are { op: "event", topic, event }, { op: "pong" } or { op: "error", message }.

ws.ts
// Browsers, Node 22+ (global WebSocket), or the `ws` package on older Node.
const ws = new WebSocket('wss://api.thehatchery.site/ws')
ws.onopen = () => {
  ws.send(JSON.stringify({ op: 'subscribe', topics: ['trades', 'tokens', `token:${mint}`] }))
  setInterval(() => ws.send(JSON.stringify({ op: 'ping' })), 25_000)
}
ws.onmessage = (m) => {
  const msg = JSON.parse(String(m.data))
  if (msg.op !== 'event') return // 'pong' | 'error'
  const e = msg.event
  if (e.type === 'trade') console.log(e.trade.side === 'BUY' ? 'Consume' : 'Purge', e.trade.quoteAmount, e.trade.symbol)
  if (e.type === 'token:new') console.log('new token', e.token.mint)
  // Only emitted when holder reflections are on (fees.reflectionBps > 0 in /config).
  if (e.type === 'reflection') console.log('paid holders', e.distributedQuote, e.mint)
}
Read fees and thresholds from GET /config at runtime rather than hardcoding them. The values below are the defaults; an operator can change the split (not the total) later.
ParameterDefault
fees.tradeFeeBps313: 3.13% of the quote side of every trade, in $BITCOIN (fixed in the DBC config)
fees.creatorBps50: 0.5% to the wallet that spawned the token
fees.reflectionBps50: 0.5% to holders of the traded token, pro-rata
fees.gremlinBps100: 1% pooled for Sproto Gremlin NFT holders, split per Gremlin held (GET /gremlins)
fees.platformBps50.4: 0.504% platform treasury
fees.protocolBps62.6: 0.626% Meteora protocol (20% of the fee, fixed)
quote$BITCOIN on Solana: the quote mint (see GET /config), SPL Token or Token-2022
token1,000,000,000 supply, 6 decimals, no mint authority
curve.migrationQuoteThreshold$BITCOIN the curve must raise to graduate; set per deployment
  1. CURVE. Trades hit the Meteora Dynamic Bonding Curve. progress is the share of the graduation threshold raised. The site shows it as an evolution stage: Larva under 10%, Cocoon 10–66%, Mutating over 66%.
  2. COMPLETE. The curve is full and the pool is migrating (Hatching). Trading pauses (409) for a short time.
  3. MIGRATED. Liquidity moves to a Meteora DAMM v2 pool (dammPool on the token) and the LP is permanently locked (Hatched). The same fee and split apply, and trades route via Jupiter.

Every few minutes the platform claims each token's fee and splits it in software. The creator share goes straight to the creator wallet in $BITCOIN and the platform share to the treasury; each claim is published at /tokens/:mint/reflections (creatorQuote, platformQuote) and totals show as creatorEarnedQuote on the token and creatorTotalQuote at /wallets/:address/earnings. This works the same before and after graduation.

Holder reflections are off by default (reflectionBps: 0). If an operator turns them on, the holder share of each claim is paid to every holder pro-rata in $BITCOIN, automatically, with no claim step. Shares too small to send carry over and show at GET /wallets/:address/claimable; a holder can withdraw them early with POST /claims/build ({ owner }), signing the vault-signed transaction as fee payer and reporting it with POST /claims/:id/confirm { signature }.

Errors are { error: string, details?: unknown } with a 4xx or 5xx status. The /rpc proxy is the exception: it answers in JSON-RPC error format.

examples
// 400: validation (details lists each field)
{ "error": "invalid request", "details": [{ "path": "amount", "message": "amount must be a positive decimal" }] }

// 422: the built transaction failed simulation
{ "error": "price moved beyond your slippage; try again or raise slippage", "details": { "logs": ["…"] } }
StatusMeaning
400Validation failed. details is a list of { path, message } per field. Also amounts that round to zero.
404Unknown token or route, or the pool is not on chain yet.
409Mint address already in use, or the curve is complete and graduating (trading pauses until DAMM v2).
413Request body over 6 MB (the metadata image).
422Simulation failed (slippage, balance, missing SOL for fees), not enough liquidity, or route too large for one transaction. error is human-readable; details.logs holds the last program logs.
429Rate limited. Read the RateLimit and RateLimit-Policy headers and back off.
502Upstream failure: Jupiter had no route, or IPFS pinning failed. Usually safe to retry.
503A dependency is down (uploads not configured, database or cache unavailable).

Rate limits

Per IP, fixed windows. Every response carries RateLimit and RateLimit-Policy headers; a 429 means wait for the window to reset.

ScopeLimit
Every endpoint600 / min / IP
/launch/build, /trade/build, /trade/quote60 / min / IP
/launch/metadata20 / hour / IP
/rpc300 / min / IP
  • Never send a private key to the API. No endpoint accepts one and the server never needs one. You only send public keys; signing happens on your machine.
  • A bot signs whatever the server returns, so check it first: deserialize the transaction and confirm the fee payer (message.staticAccountKeys[0]) is your wallet, and compare the quote (inAmount, minOut) against what you asked for. Cap trade sizes and slippage in your own code.
  • Use a dedicated hot wallet holding only what the bot needs. Keep the keypair file out of version control.
  • Always double-check the mint address. Anyone can spawn a token with any name and symbol.

New to The Hatchery? How it works covers the product side.