Developers
Build on PairsFun — launch multipairs from your own site
You collect the token details on your page. We build the transactions. The creator signs them in their own wallet, and the token goes live on PairsFun with the same pools, the same lock and the same fees. You earn a share of what the creator earns. Neither of us ever holds a key.
The flow
- 1Prepare
POST /v1/launcheswith the token, the basket of assets and the creator's wallet. You get unsigned transactions and areviewto show the creator: the fee split, the plan and any warnings. - 2The creator signsIn their own wallet, once for the first phase (the address table, and swaps if the creator buy is paid in SOL) and once for the launch itself.
- 3SubmitThe first phase goes to the network through
POST /v1/rpc. The launch goes throughPOST /v1/launches/{id}/submit, as one all-or-nothing bundle when the creator buys. - 4LiveThe token is listed on pairsfun.xyz. Your share of the creator share is in the fee vault on-chain; claim it any time with your payout wallet.
All money logic is the same code that runs pairsfun.xyz: pool prices, the fee tier, the lock, the anti-snipe creator buy. The API only wraps it with keys, limits and a stable error format.
Quick start
TypeScript in the browser, with fetch only. The one library you need is the one that reads and writes transactions for the wallet. Replace KEY with your publishable key (it works from the origins we registered for you).
import { VersionedTransaction } from '@solana/web3.js'
const API = 'https://pairsfun.xyz/v1'
const KEY = 'pfp_...' // publishable key: browser only
type Wallet = {
publicKey: { toString(): string }
signAllTransactions(txs: VersionedTransaction[]): Promise<VersionedTransaction[]>
}
async function api(path: string, body?: unknown, headers: Record<string, string> = {}) {
const res = await fetch(API + path, {
method: body === undefined ? 'GET' : 'POST',
headers: { authorization: `Bearer ${KEY}`, 'content-type': 'application/json', ...headers },
body: body === undefined ? undefined : JSON.stringify(body),
})
const json = await res.json()
// Errors always look the same: { error: { code, message, status, requestId, details? } }
if (!res.ok) throw Object.assign(new Error(json.error.message), json.error)
return json
}
const rpc = (method: string, params: unknown[]) =>
api('/rpc', { jsonrpc: '2.0', id: 1, method, params }).then((j) => {
if (j.error) throw new Error(j.error.message)
return j.result
})
const decode = (b64: string) => VersionedTransaction.deserialize(Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)))
const encode = (tx: VersionedTransaction) => btoa(String.fromCharCode(...tx.serialize()))
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
async function sendAndConfirm(signed: string) {
const sig: string = await rpc('sendTransaction', [signed, { encoding: 'base64', preflightCommitment: 'confirmed' }])
for (let i = 0; i < 60; i++) {
const st = (await rpc('getSignatureStatuses', [[sig]])).value[0]
if (st?.err) throw new Error('transaction failed: ' + JSON.stringify(st.err))
if (st?.confirmationStatus === 'confirmed' || st?.confirmationStatus === 'finalized') return sig
await sleep(1500)
}
throw new Error('not confirmed in time')
}
export async function launch(
wallet: Wallet,
token: { name: string; symbol: string; image?: string; basket: { mint: string; weightBps: number }[]; devBuyUsd?: number },
) {
const creator = wallet.publicKey.toString()
// 1. Prepare. Send an Idempotency-Key: a retry with the same key returns the same launch.
const prepared = await api('/launches', {
creator, name: token.name, symbol: token.symbol, image: token.image, basket: token.basket,
...(token.devBuyUsd ? { devBuy: { usd: token.devBuyUsd, pay: 'sol' } } : {}),
}, { 'idempotency-key': crypto.randomUUID() })
const id: string = prepared.launch.id
// Show prepared.review to the creator and wait for a yes:
// review.feePolicy is the split of the creator share (your share is in it), plus plan and warnings.
// 2. First phase: swaps (if any), then the address table. One wallet window, sent in order.
const first = [...prepared.phase1.swapTransactions.map((s: { transaction: string }) => decode(s.transaction)), ...prepared.phase1.transactions.map(decode)]
const { blockhash } = (await rpc('getLatestBlockhash', [{ commitment: 'confirmed' }])).value
for (const tx of first) tx.message.recentBlockhash = blockhash // unsigned: refresh right before signing
for (const tx of await wallet.signAllTransactions(first)) await sendAndConfirm(encode(tx))
// 3. Ask for the bundle. 409 table_not_ready until the address table is final: retry once a second.
let bundle: any
for (let i = 0; i < 40 && !bundle; i++) {
try {
bundle = await api(`/launches/${id}/bundle`, {})
} catch (e: any) {
if (e.code !== 'table_not_ready' && e.code !== 'swaps_pending') throw e
await sleep(1200)
}
}
if (!bundle) throw new Error('the address table did not become final in time')
// 4. The creator signs the launch bundle and the bookkeeping together, then you send the bundle.
const n = bundle.transactions.length
const signed = (await wallet.signAllTransactions([...bundle.transactions, ...bundle.followUpTransactions].map(decode))).map(encode)
await api(`/launches/${id}/submit`, { stage: 'bundle', transactions: signed.slice(0, n) })
// 5. When the token exists on-chain, send the bookkeeping that makes it Live.
// Bundles are sometimes dropped: if the mint never shows up, nothing was launched. Go back to step 3 for a fresh bundle,
// have the creator sign it, send it again. Do not send the bookkeeping for a token that does not exist.
let landed = false
for (let i = 0; i < 40 && !landed; i++) {
landed = !!(await rpc('getAccountInfo', [prepared.launch.mint, { encoding: 'base64' }])).value
if (!landed) await sleep(1500)
}
if (!landed) throw new Error('the launch bundle did not land: ask for /launches/{id}/bundle again, sign it, send it with stage "bundle"')
try {
await api(`/launches/${id}/submit`, { stage: 'followup', transactions: signed.slice(n) })
} catch (e: any) {
// The bookkeeping can fail on its own (an old blockhash is the usual reason). details.signatures lists what already landed:
// /finalize gives the rest with a fresh blockhash, the creator signs it, you send it.
if (e.code !== 'submit_failed') throw e
const again = await api(`/launches/${id}/finalize`, { from: e.details?.signatures?.length ?? 0 })
const resigned = (await wallet.signAllTransactions(again.transactions.map(decode))).map(encode)
await api(`/launches/${id}/submit`, { stage: 'followup', transactions: resigned })
}
return prepared.launch.mint as string
}
Check what you ask a wallet to sign. The transactions come from us, but your page is what the creator trusts. Show them review.feePolicy and the warnings, and compare review.policyDigest with GET /v1/launches/{id} if you want a second look. Before the bundle is handed out we decode the fee split inside it and refuse with policy_mismatch if it differs from the one shown. Read-only use needs no wallet at all: the catalog, quotes and token pages work the same way.
Trading works the same way: POST /v1/tokens/{mint}/quote, then .../trade for unsigned transactions, the wallet signs, POST /v1/submit sends them in order. If a multi-transaction trade lands half way, the error carries details.unfinished: send it back as resume to finish only what is missing.
From a server, use your secret key and the same calls. Add X-Pairsfun-End-User-Ip with the visitor's public address so per-person limits apply to them and not to your whole site (a private or loopback address is refused with validation_failed). These limits are counted in your own account, never against other visitors of pairsfun.xyz.
Fees
Same fees as pairsfun.xyz. The pools, the fee tier, the split and the liquidity lock are decided by the program and the server, not by the request, so a token launched from your site is exactly the token the creator would get on ours.
- Every swap pays a 1% pool fee. The pool program keeps 16% of it.
- Of the rest, on Standard and Creator-tax tokens: 70% is the creator share (your cut comes out of it) and 30% is PairsFun's share. Reward and Custom tokens have no creator share; all of it is PairsFun's share.
- PairsFun's share goes to the PairsFun treasury. PairsFun has no token yet, so nothing is burned. The program can hold part of the share on-chain for a future burn;
burnShareBpssays how much (0 means none).
The live numbers are in GET /v1/config (fees) and at /api/protocol.
How you earn
- Your payout wallet is added to the token's fee vault as one more recipient of the creator share. Your cut is set in basis points of that share and comes out of the creator's part. The protocol share is never touched.
- The size is set on your key. Send
partnerShareBpsto ask for less, never more. If you leave it out you get the default of your key. - You earn on Standard and Creator tax tokens. Reward and Custom tokens have no creator share, so there is nothing to split; the response says
earns: falseand why. - The creator sees your share in
review.feePolicybefore they sign, and the wallet signs it. It is written into the fee vault on-chain. Until the first fee collection the creator can still change the split; if they remove you, you stop earning on that token and the "via" label goes away. After the first collection it is frozen. - Your share and the creator's own cut are paid in the assets the pools collect. You claim with
GET /v1/earningsandPOST /v1/claim(secret key): we build the transaction, you sign it with your payout wallet.
A token launched through you shows via your name on pairsfun.xyz and in the API. We add it only after the chain confirms that your wallet is in the fee vault with the share we promised: attribution on GET /v1/launches/{id} goes pending, then verified.
Keys
pfp_... for your front end. A browser sends the origin of the page that calls, and the key works only from the origins registered for your account, so a copy of it on another website does not work in a browser. A script can send any Origin header, though: treat this key as public. Preparing a launch costs the creator nothing, so it is limited per visitor address as well (see Limits), and a launch counts against your daily quota only once its creator has paid for the address table. Everything a launch page needs, nothing about your earnings or usage.
pfs_... for your server. It sees your earnings, usage and launches, and it is refused if the request carries an Origin header, i.e. if it ever shows up in front-end code.
Send a key as Authorization: Bearer <key>. We store only a hash, so a lost key can't be shown again, only replaced. A revoked key stops working within seconds. Browsers call from your origin; preflight is answered for every registered origin.
Limits
Per key, per minute unless it says otherwise. Higher limits on request.
| What | Limit |
|---|---|
Prepare a launch (POST /launches) | 30 / min |
| Launches per day (UTC), counted when the creator has paid for the address table | 200 |
| Unfinished launches at once (one with a paid address table stays open for 30 minutes, one without for 5) | 25 |
| Launches per creator wallet (publishable key: per wallet and visitor address) | 5 / min |
| Publishable key: launches prepared from one visitor address (IPv6 by /64) | 2 / 5 min, 10 / day |
| Quotes | 300 / min |
| Trades (build transactions) | 120 / min |
| Submit signed transactions | 60 / min |
| Reads: catalog, tokens, launch status, the bundle poll | 600 / min |
| JSON-RPC proxy | 600 / min |
JSON-RPC proxy: sendTransaction and simulateTransaction | 60 / min |
On top of that the same per-person limits as on pairsfun.xyz apply to each visitor (by IP), counted separately for your account. Over a limit you get 429 with Retry-After, the same number in error.retryAfterSec. RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset come on every answer to a limited call, errors and the 429 itself included (the open calls, such as /config, have none).
Errors
HTTP 422
{
"error": {
"code": "brand_copy",
"message": "This name or ticker copies PairsFun. Pick another one. Nothing has been charged.",
"status": 422,
"requestId": "req_9f2c41d07a3b55e1",
"details": { "field": "name", "reason": "the name copies PairsFun" }
}
}
Branch on code, show message, quote requestId when you write to us. The text can change, the code does not. The ones you will meet:
| Code | What happened |
|---|---|
| unauthorized, key_revoked | No key, an unknown key, or one you revoked. |
| forbidden_origin | A publishable key from an origin that is not registered, or a secret key from a browser. |
| partner_suspended | Your account is paused on our side. Write to us. |
| rate_limited, quota_exceeded | Over a per-minute limit, or the daily or unfinished-launch quota. Wait retryAfterSec. |
| busy | We are preparing many launches right now. Retry after retryAfterSec (a few seconds). |
| validation_failed | Something in the request is wrong; details.field says what. |
| brand_copy, moderation_blocked | The name copies PairsFun, or a word, image or wallet is on our block list. |
| launches_closed | Launching is paused; GET /v1/config says why. |
| table_not_ready | The address table is not final yet. Retry the bundle call in a second. |
| idempotency_conflict | The same Idempotency-Key with a different body. |
| already_live | Asked for the bundle of a launch that is already live. Nothing to do: read GET /launches/{id}. |
| policy_mismatch | The fee split we built does not match the one shown, so we did not hand out the transactions. Nothing was signed; start the launch again. |
| upstream_unavailable | The network node did not answer. Try again in a moment. |
| submit_failed | A transaction did not land. details lists what did, and what to resend. |
| price_impact, slippage, too_small ... | Trade refusals with the reason as the code. |
Endpoints
Base URL https://pairsfun.xyz/v1 (also under /api/v1). The full contract is the OpenAPI spec.
| Call | What it does |
|---|---|
| GET /config | Launching open or not, limits on a launch, fee models. No key needed. |
| GET /me | Your account: share, limits, usage today. |
| GET /pairs, /pairs/{mint} | Assets a token can be paired with, and one asset in detail. |
| GET /launches | Live tokens, newest first. ?partner=me for yours (the secret key also gets each launch's launchId and your endUserRef). |
| GET /tokens/{mint} | One token: basket, pools, stats, proofs. |
| POST /launches | Prepare a launch (step 1). |
| GET /launches/{id} | State, fee split, attribution. |
| POST /launches/{id}/bundle | The launch bundle (step 3). |
| POST /launches/{id}/submit | Send the signed bundle, then the bookkeeping (step 4). |
| POST /launches/{id}/finalize | Rebuild the bookkeeping with a fresh blockhash. |
| POST /rpc | Read methods and sendTransaction, under your limits. |
| POST /tokens/{mint}/quote, /trade | Quote and build a buy or a sell. |
| POST /submit | Send signed trade transactions in order. |
| GET /usage, /earnings, POST /claim | Secret key only: usage, what your wallet can claim, the claim transaction. |
What we check
- Names and tickers that copy PairsFun are refused before anything is built. A name that is not a copy goes through, and the usual rules apply after launch.
- Words, images and wallets on our block list are refused before anything is built.
- Every asset in the basket is read from the chain: issuer powers, extensions, whether the pool can hold it. Assets outside our catalog launch marked unverified with the issuer's powers in
warnings. Show them. - Prices and the pool tier come from the server, never from your request, and the fee split is checked against what you were shown (
policy_mismatchif it ever differs). Fields we do not know are an error, not ignored. - A refusal costs nothing: until the creator signs in their wallet, nothing is sent and nothing is charged.
Get a key
DM @pairsfun_xyz on X. Tell us your site, the origins that will call the API, the wallet that should receive your share, and roughly how many launches you expect. We reply with a publishable and a secret key. They are shown once.
Keys can be revoked and replaced at any time. This is the only way we hand them out; anyone else offering PairsFun keys is not us.