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.
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: 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:
# 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.
{ "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.
// 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.
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) orSELL(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 (perpayWith); to purge, it is the token. Digits beyond the asset's decimals are truncated (SOL 9, the quote perquote.decimals, tokens 6).payWith:SOLorQUOTE($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
routesays so andinAmountshows the amount actually spent. - Graduating. While
statusisCOMPLETE, quotes and builds return 409 until migration finishes. - Graduated. Once
statusisMIGRATED, trades route entirely through Jupiter (the DAMM v2 pool is indexed there).feeQuotereads0in 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
/configParams: noneReturns:LaunchpadConfigDTOCluster, DBC config, quote token (
mint,symbol,decimals,name,logoUrl),feesin bps (tradeFeeBps,creatorBps,platformBps,protocolBps,reflectionBps),curve.migrationQuoteThreshold, token supply/decimals,treasury,feeClaimer,featuredMints,officialMint. Cached 60 s. - GET
/statsParams: noneReturns:PlatformStatsDTOtokens,graduated(from a curve),trebuchet(Trebuchet launches),volume24hQuote,creatorEarnedTotalQuote,reflectedTotalQuote,quoteUsd.
Tokens
- GET
/tokensParams:sortnew|volume|mcap|progress|lastTrade (new),statusCURVE|COMPLETE|MIGRATED (MIGRATED = graduated from a curve, so Trebuchet launches are left out),stageall|curve|migrating|dex|trebuchet (dex = everything on a DEX),q(name, symbol or exact mint, ≤64),creator,limit≤100 (30),cursorReturns:{ items: TokenDTO[], nextCursor }sort=progressonly lists CURVE tokens unless you passstatus. - GET
/tokens/:mintParams: noneReturns:TokenDTOPrice, market cap,
progress(0..1),status,quoteReserve, 24h stats, holders,feesTotalQuote,creatorEarnedQuote,reflectedTotalQuote,pendingReflectionQuote. 404 if unknown. - GET
/tokens/:mint/circulating-supplyParams: noneReturns:{ 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/tradesParams:limit≤200 (50),cursorReturns:Paginated<TradeDTO>Newest first.
venueis DBC (curve), DAMM_V2 (graduated) or CLMM (a Trebuchet launch's Raydium pool). - GET
/tokens/:mint/candlesParams:tf1m|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/holdersParams:limit≤200 (50)Returns:HolderDTO[]shareis 0..1 of total supply;labelmarks the curve, graduated pool, platform and creator. - GET
/tokens/:mint/reflectionsParams:limit≤100 (20),cursorReturns: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/recentParams:limit≤50 (20)Returns:TradeDTO[]All tokens, newest first. A plain array, not paginated.
Wallets & claims
- GET
/wallets/:address/earningsParams:mint(optional filter)Returns:WalletEarningsResponseCreator side:
creatorTotalQuoteandcreatedper token launched. Holder side:totalQuoteand per-tokentokens. Confirmed payouts only. - GET
/wallets/:address/reflectionsParams:limit≤100 (30),cursorReturns:Paginated<ReflectionPayoutDTO>+totalQuoteEvery holder reflection payout received, with its transaction
signature. - GET
/wallets/:address/tradesParams:limit≤100 (30),cursorReturns:Paginated<TradeDTO> - GET
/wallets/:address/claimableParams: noneReturns:ClaimableDTOHolder reflections owed but not sent yet:
claimableQuote,accruedQuote,claimedQuote,pending,minClaimQuote. Only relevant when reflections are on. - POST
/claims/buildParams:{ owner }Returns:ClaimBuildResponseEarly withdrawal of claimable reflections: a vault-signed transaction the owner signs as fee payer. 503 if claims are not enabled.
- POST
/claims/:id/confirmParams:{ signature }Returns:{ status }LANDED, or 202 PENDING if the transaction is not visible yet.
Spawn
- POST
/launch/metadataParams:imageReturns:{ imageUrl }Pins the image to IPFS. 20 uploads per hour per IP.
- POST
/launch/buildParams:creator,name,symbol,imageUrl,mint?,description?,twitter?,telegram?,firstBuy?Returns:BuiltTransaction+mint,mintSignedCreator is fee payer. Simulated before it is returned.
Trade
- GET
/trade/quoteParams:mint,side,amount,payWith,slippageBps(query string)Returns:TradeQuoteRead-only. Shares the 60/min transaction-build limit.
- POST
/trade/buildParams: same as quote +owner(JSON body)Returns:BuiltTransactionOwner is fee payer. Simulated before it is returned (except split pre-transaction flows).
RPC proxy, WebSocket, health
- POST
/rpcParams: JSON-RPC 2.0 request or batch (≤20)Returns: Upstream JSON-RPC response300 requests/min per IP. Allowlisted methods only (below).
- WS
wss://api.thehatchery.site/wsParams:subscribe/unsubscribe/pingReturns:WsServerMessageSee WebSocket below.
- GET
/healthParams: noneReturns:{ 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:
sendTransactionsimulateTransactiongetLatestBlockhashgetSignatureStatusesgetBalancegetAccountInfogetMultipleAccountsgetTokenAccountsByOwnergetTokenAccountBalancegetSlotgetBlockHeightgetEpochInfogetFeeForMessagegetGenesisHashgetVersiongetMinimumBalanceForRentExemptiongetRecentPrioritizationFeesisBlockhashValidcurl -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.
| Topic | Events |
|---|---|
| trades | trade for every token: { trade: TradeDTO, token } with the token's new price, market cap, progress and status. |
| tokens | token: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 }.
// 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)
}GET /config at runtime rather than hardcoding them. The values below are the defaults; an operator can change the split (not the total) later.| Parameter | Default |
|---|---|
| fees.tradeFeeBps | 313: 3.13% of the quote side of every trade, in $BITCOIN (fixed in the DBC config) |
| fees.creatorBps | 50: 0.5% to the wallet that spawned the token |
| fees.reflectionBps | 50: 0.5% to holders of the traded token, pro-rata |
| fees.gremlinBps | 100: 1% pooled for Sproto Gremlin NFT holders, split per Gremlin held (GET /gremlins) |
| fees.platformBps | 50.4: 0.504% platform treasury |
| fees.protocolBps | 62.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 |
| token | 1,000,000,000 supply, 6 decimals, no mint authority |
| curve.migrationQuoteThreshold | $BITCOIN the curve must raise to graduate; set per deployment |
- CURVE. Trades hit the Meteora Dynamic Bonding Curve.
progressis the share of the graduation threshold raised. The site shows it as an evolution stage: Larva under 10%, Cocoon 10–66%, Mutating over 66%. - COMPLETE. The curve is full and the pool is migrating (Hatching). Trading pauses (409) for a short time.
- MIGRATED. Liquidity moves to a Meteora DAMM v2 pool (
dammPoolon 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.
// 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": ["…"] } }| Status | Meaning |
|---|---|
| 400 | Validation failed. details is a list of { path, message } per field. Also amounts that round to zero. |
| 404 | Unknown token or route, or the pool is not on chain yet. |
| 409 | Mint address already in use, or the curve is complete and graduating (trading pauses until DAMM v2). |
| 413 | Request body over 6 MB (the metadata image). |
| 422 | Simulation 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. |
| 429 | Rate limited. Read the RateLimit and RateLimit-Policy headers and back off. |
| 502 | Upstream failure: Jupiter had no route, or IPFS pinning failed. Usually safe to retry. |
| 503 | A 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.
| Scope | Limit |
|---|---|
| Every endpoint | 600 / min / IP |
| /launch/build, /trade/build, /trade/quote | 60 / min / IP |
| /launch/metadata | 20 / hour / IP |
| /rpc | 300 / 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 thequote(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.