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

  1. 1
    PreparePOST /v1/launches with the token, the basket of assets and the creator's wallet. You get unsigned transactions and a review to show the creator: the fee split, the plan and any warnings.
  2. 2
    The 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.
  3. 3
    SubmitThe first phase goes to the network through POST /v1/rpc. The launch goes through POST /v1/launches/{id}/submit, as one all-or-nothing bundle when the creator buys.
  4. 4
    LiveThe 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; burnShareBps says how much (0 means none).

The live numbers are in GET /v1/config (fees) and at /api/protocol.

How you earn

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

publishable

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.

secret

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.

WhatLimit
Prepare a launch (POST /launches)30 / min
Launches per day (UTC), counted when the creator has paid for the address table200
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
Quotes300 / min
Trades (build transactions)120 / min
Submit signed transactions60 / min
Reads: catalog, tokens, launch status, the bundle poll600 / min
JSON-RPC proxy600 / min
JSON-RPC proxy: sendTransaction and simulateTransaction60 / 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:

CodeWhat happened
unauthorized, key_revokedNo key, an unknown key, or one you revoked.
forbidden_originA publishable key from an origin that is not registered, or a secret key from a browser.
partner_suspendedYour account is paused on our side. Write to us.
rate_limited, quota_exceededOver a per-minute limit, or the daily or unfinished-launch quota. Wait retryAfterSec.
busyWe are preparing many launches right now. Retry after retryAfterSec (a few seconds).
validation_failedSomething in the request is wrong; details.field says what.
brand_copy, moderation_blockedThe name copies PairsFun, or a word, image or wallet is on our block list.
launches_closedLaunching is paused; GET /v1/config says why.
table_not_readyThe address table is not final yet. Retry the bundle call in a second.
idempotency_conflictThe same Idempotency-Key with a different body.
already_liveAsked for the bundle of a launch that is already live. Nothing to do: read GET /launches/{id}.
policy_mismatchThe 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_unavailableThe network node did not answer. Try again in a moment.
submit_failedA 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.

CallWhat it does
GET /configLaunching open or not, limits on a launch, fee models. No key needed.
GET /meYour account: share, limits, usage today.
GET /pairs, /pairs/{mint}Assets a token can be paired with, and one asset in detail.
GET /launchesLive 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 /launchesPrepare a launch (step 1).
GET /launches/{id}State, fee split, attribution.
POST /launches/{id}/bundleThe launch bundle (step 3).
POST /launches/{id}/submitSend the signed bundle, then the bookkeeping (step 4).
POST /launches/{id}/finalizeRebuild the bookkeeping with a fresh blockhash.
POST /rpcRead methods and sendTransaction, under your limits.
POST /tokens/{mint}/quote, /tradeQuote and build a buy or a sell.
POST /submitSend signed trade transactions in order.
GET /usage, /earnings, POST /claimSecret key only: usage, what your wallet can claim, the claim transaction.

What we check

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.