0Bit Documentation

Build a headless 0Pools flow

End-to-end server-side recipe for an approved 0Pools partner: discover, quote, transact, poll, and reconcile a pool-backed conversion over the headless liquidity API.

This guide is the full server-side lifecycle for an approved 0Pools partner running its own UX over the headless liquidity API: discover an entitled pool, read its capabilities, lock a firm quote, transact, poll to settlement, and reconcile. Every money step runs from your backend with a secret (sk_) key.

Most integrations should use 0Gate

The headless 0Pools API is for approved VASPs that run their own customer experience and own KYC/KYB and compliance obligations. If you do not need to own the UX, use the hosted 0Gate flow instead. 0Pools is gated early access: access is operator-provisioned per partner, not self-serve, and not generally available.

On-ramp is the supported path

The on-ramp (buy: fiat → crypto) path is the supported flow. The off-ramp (sell) path is gated and returns 501 at transact until your account is enabled for it. Design for on-ramp first and treat off-ramp as forward-looking.

Lifecycle at a glance

All money and rate values in 0Pools are decimal strings (never floats); basis-point fields are integers. The base URL is https://pools-api.0bit.app/v1.

0. Prerequisites

Why: the headless surface is entitlement-gated, so nothing below works until 0Bit has provisioned your access and you hold the right key type.

  • Approved early-access entitlement. Access is operator-provisioned, with an account status (active or suspended), a KYC state (approved, pending, or revoked), a tier, and an allowlist of pools you may use. See Getting access for how access is granted, and Entitled pool network for what it gates.
  • An sk_ secret key, server-only. Publishable pk_ keys are limited to pool discovery; every other call below requires a secret key and must run from your backend. Never ship an sk_ key to a browser or mobile client.
  • An entitled pool. Cross-tenant access resolves as 404, never 403, so you can only ever quote pools on your allowlist.

1. Discover entitled pools

Why: pool ids are pair-shaped (for example EUR-USDT) and the set you can see is scoped to your entitlement. Discover them rather than hard-coding ids. This is the one call that accepts a publishable pk_ key.

curl https://pools-api.0bit.app/v1/pools \
  -H "Authorization: Bearer pk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
{
  "pools": [
    { "id": "EUR-USDT", "pair": "EUR/USDT", "fiatCurrency": "EUR", "cryptoCurrency": "USDT", "available": true }
  ]
}

Pick a pool where available is true. Treat availability as dynamic — never assume a pool is permanently live. See Discover entitled pools.

Why: read supported networks, sides, fees, and order limits from the pool instead of hard-coding them. They are confirmed during account review and can change per partner. This call requires a secret key and entitlement to the pool.

curl https://pools-api.0bit.app/v1/pools/EUR-USDT/capabilities \
  -H "Authorization: Bearer sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
{
  "poolId": "EUR-USDT",
  "pair": "EUR/USDT",
  "fiatCurrency": "EUR",
  "cryptoCurrency": "USDT",
  "supportedNetworks": ["tron", "ethereum", "bsc", "polygon", "solana"],
  "sides": ["on_ramp"],
  "tier": "standard",
  "drawFeeBps": 30,
  "spreadCapBps": 50,
  "minOrderUsdt": 10,
  "maxOrderUsdt": 50000,
  "cryptoDepositAddress": null
}

Validate your intended side, cryptoNetwork, and amount against this response before quoting. See Check pool balances and availability.

3. Lock a firm quote

Why: a firm quote is a short-lived, single-use price lock. It returns a quoteId and an expiresAt roughly 15 seconds out — the only thing you can pass to transact.

For an on-ramp buy, amount is the fiat you pay in, and destAddress (the EVM wallet the crypto is delivered to) is required and locked onto the quote at this step — it cannot be changed later. destNetwork is the EVM delivery network and defaults to the quoted cryptoNetwork when omitted.

curl -X POST https://pools-api.0bit.app/v1/pools/EUR-USDT/quote \
  -H "Authorization: Bearer sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "side": "on_ramp",
    "fiatCurrency": "EUR",
    "cryptoCurrency": "USDT",
    "amount": "100.00",
    "cryptoNetwork": "tron",
    "type": "firm",
    "destAddress": "0x52908400098527886E0F7030069857D2E4169EE7",
    "destNetwork": "arbitrum"
  }'
{
  "available": true,
  "type": "firm",
  "executable": true,
  "quoteId": "pq_test_8f3c000000000123",
  "rate": "0.9940",
  "spreadBps": 25,
  "feeBps": 30,
  "minOrderUsdt": 10,
  "maxOrderUsdt": 50000,
  "expiresAt": "2026-01-01T00:00:15Z"
}

Handle the fail-soft available:false

The quote call returns HTTP 200 even when a pool cannot be priced: you get available: false with an unavailableReason (pool_dry, engine_unavailable, or rate_unavailable) and no quoteId. This is a normal outcome, not an error — show an unavailable state and back off. Never call transact without a quoteId.

Store the quoteId and expiresAt, then move to transact before it expires. See Lock a pool quote.

4. Transact

Why: transact reserves the trade against your firm quote. It is a money write, so the Idempotency-Key header is required. Transact is also idempotent on the quoteId itself — re-posting the same quote returns the same trade rather than creating a second one.

curl -X POST https://pools-api.0bit.app/v1/pools/EUR-USDT/transact \
  -H "Authorization: Bearer sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 00000000-0000-4000-8000-000000000123" \
  -d '{ "quoteId": "pq_test_8f3c000000000123" }'
{
  "transactId": "txn_test_456",
  "status": "reserved",
  "quoteId": "pq_test_8f3c000000000123",
  "idempotent": false
}

Reuse the same Idempotency-Key (and the same body) when retrying a timeout. Reusing a key with a different body is rejected as 409 (idempotency-conflict). Persist transactId and quoteId before you advance any user-visible state. See Execute against a pool quote.

5. Poll to settlement

Why: a reserved trade is not final. Polling the status endpoint by quoteId is the intended, idempotent way to advance a reserved trade toward a terminal state.

curl https://pools-api.0bit.app/v1/pools/transactions/pq_test_8f3c000000000123 \
  -H "Authorization: Bearer sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
{
  "transactId": "txn_test_456",
  "quoteId": "pq_test_8f3c000000000123",
  "status": "settled",
  "poolId": "EUR-USDT",
  "side": "on_ramp",
  "createdAt": "2026-01-01T00:00:02Z",
  "settledAt": "2026-01-01T00:00:06Z"
}

The status progresses reserved → settled | failed | released. A terminal settled trade can later flip to returned (a downstream payout returned by the bank, or a crypto delivery that could not be completed), with an automatic, idempotent refund to your partner balance. returned surfaces only through this poll and the ledger — there is no webhook for it.

6. Reconcile

Why: webhooks are the source of truth for settlement; polling and reads are the backstop when delivery is delayed, dead-lettered, or you need to recover a stuck trade.

  • Webhooks (primary): 0Pools emits pool.transaction.settled, pool.transaction.failed, and pool.transaction.released. Verify the Gate-Signature header over the raw body, dedupe on eventId (= sha256(txnId:eventType)), and apply each terminal transition once.
  • Reads (fallback): reconcile from GET /v1/pools/trades (cursor-paginated, filterable by side/status/created range) and, for EUR balance movements, GET /v1/pools/ledger. These are pure reads — unlike the status poll, they do not advance settlement. The ledger is where you will see the refund entry for a returned trade.
# Cursor-paginated trade reconciliation (pure read)
curl "https://pools-api.0bit.app/v1/pools/trades?limit=50&status=settled" \
  -H "Authorization: Bearer sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

# EUR balance ledger (find returned-trade refunds here)
curl "https://pools-api.0bit.app/v1/pools/ledger?currency=EUR&limit=50" \
  -H "Authorization: Bearer sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

See Track pool trades and the 0Pools webhooks reference.

Full server-side recipe

The same lifecycle (steps 1 → 5, with a short reconcile read) in one runnable script per language. Keep the secret key in your server environment; the publishable key is only used for discovery.

PK="pk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
SK="sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
BASE="https://pools-api.0bit.app/v1"

# 1. Discover (pk_)
curl -s "$BASE/pools" -H "Authorization: Bearer $PK"

# 2. Capabilities (sk_)
curl -s "$BASE/pools/EUR-USDT/capabilities" -H "Authorization: Bearer $SK"

# 3. Lock a firm quote — capture quoteId from the response
curl -s -X POST "$BASE/pools/EUR-USDT/quote" \
  -H "Authorization: Bearer $SK" -H "Content-Type: application/json" \
  -d '{"side":"on_ramp","fiatCurrency":"EUR","cryptoCurrency":"USDT","amount":"100.00","cryptoNetwork":"tron","type":"firm","destAddress":"0x52908400098527886E0F7030069857D2E4169EE7","destNetwork":"arbitrum"}'

# 4. Transact (Idempotency-Key required)
QUOTE_ID="pq_test_8f3c000000000123"
curl -s -X POST "$BASE/pools/EUR-USDT/transact" \
  -H "Authorization: Bearer $SK" -H "Content-Type: application/json" \
  -H "Idempotency-Key: 00000000-0000-4000-8000-000000000123" \
  -d "{\"quoteId\":\"$QUOTE_ID\"}"

# 5. Poll to settlement (this poll advances settlement)
curl -s "$BASE/pools/transactions/$QUOTE_ID" -H "Authorization: Bearer $SK"

# 6. Reconcile (pure reads)
curl -s "$BASE/pools/trades?limit=50" -H "Authorization: Bearer $SK"
const BASE = 'https://pools-api.0bit.app/v1';
const PK = process.env.POOLS_PK!; // publishable, discovery only
const SK = process.env.POOLS_SK!; // secret, server-only

const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

async function call(path: string, key: string, init: RequestInit = {}) {
  const res = await fetch(`${BASE}${path}`, {
    ...init,
    headers: { Authorization: `Bearer ${key}`, ...(init.headers ?? {}) },
  });
  const body = await res.json();
  if (!res.ok) {
    // Unified error envelope: branch on code/type, not on the message.
    throw Object.assign(new Error(body.message), {
      code: body.code,
      type: body.type,
      statusCode: body.statusCode,
      requestId: res.headers.get('X-Request-Id'),
    });
  }
  return body;
}

export async function buyWithPool(destAddress: string) {
  // 1. Discover entitled pools (pk_)
  const { pools } = await call('/pools', PK);
  const pool = pools.find((p: any) => p.available);
  if (!pool) return { state: 'no_pool' };

  // 2. Read capabilities (sk_) instead of hard-coding networks/limits
  const caps = await call(`/pools/${pool.id}/capabilities`, SK);

  // 3. Lock a firm quote (sk_)
  const quote = await call(`/pools/${pool.id}/quote`, SK, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      side: 'on_ramp',
      fiatCurrency: caps.fiatCurrency,
      cryptoCurrency: caps.cryptoCurrency,
      amount: '100.00', // decimal STRING; on_ramp amount = fiat in
      cryptoNetwork: caps.supportedNetworks[0],
      type: 'firm',
      destAddress, // required for on_ramp; locked onto the quote
      destNetwork: 'arbitrum',
    }),
  });

  // Fail-soft: HTTP 200 + available:false => no quoteId. Never transact.
  if (!quote.available || !quote.executable) {
    return { state: 'unavailable', reason: quote.unavailableReason };
  }

  // 4. Transact — Idempotency-Key REQUIRED; reuse it (and body) on retry
  const idempotencyKey = crypto.randomUUID();
  let tx;
  try {
    tx = await call(`/pools/${pool.id}/transact`, SK, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', 'Idempotency-Key': idempotencyKey },
      body: JSON.stringify({ quoteId: quote.quoteId }),
    });
  } catch (e: any) {
    if (e.statusCode === 409 && e.code === 'expired') {
      return { state: 'expired' }; // quote lapsed — go re-quote
    }
    throw e;
  }

  // 5. Poll to settlement — this poll ADVANCES the reserved trade
  let status = tx.status; // 'reserved'
  for (let i = 0; i < 30 && !['settled', 'failed', 'released', 'returned'].includes(status); i++) {
    await sleep(1000);
    const s = await call(`/pools/transactions/${quote.quoteId}`, SK);
    status = s.status;
  }

  return { state: status, transactId: tx.transactId, quoteId: quote.quoteId };
}

// 6. Reconcile (pure read; does NOT advance settlement)
export async function reconcile(cursor?: string) {
  const q = new URLSearchParams({ limit: '50', ...(cursor ? { cursor } : {}) });
  return call(`/pools/trades?${q}`, SK);
}
import os, time, uuid, requests

BASE = "https://pools-api.0bit.app/v1"
PK = os.environ["POOLS_PK"]  # publishable, discovery only
SK = os.environ["POOLS_SK"]  # secret, server-only

def call(method, path, key, **kwargs):
    res = requests.request(method, f"{BASE}{path}",
                           headers={"Authorization": f"Bearer {key}", **kwargs.pop("headers", {})},
                           **kwargs)
    body = res.json()
    if not res.ok:
        # Unified error envelope: branch on code/type, not on message.
        raise RuntimeError({
            "code": body.get("code"), "type": body.get("type"),
            "statusCode": body.get("statusCode"),
            "request_id": res.headers.get("X-Request-Id"),
        })
    return body

def buy_with_pool(dest_address: str):
    # 1. Discover entitled pools (pk_)
    pools = call("GET", "/pools", PK)["pools"]
    pool = next((p for p in pools if p["available"]), None)
    if not pool:
        return {"state": "no_pool"}

    # 2. Read capabilities (sk_) instead of hard-coding networks/limits
    caps = call("GET", f"/pools/{pool['id']}/capabilities", SK)

    # 3. Lock a firm quote (sk_)
    quote = call("POST", f"/pools/{pool['id']}/quote", SK, json={
        "side": "on_ramp",
        "fiatCurrency": caps["fiatCurrency"],
        "cryptoCurrency": caps["cryptoCurrency"],
        "amount": "100.00",  # decimal STRING; on_ramp amount = fiat in
        "cryptoNetwork": caps["supportedNetworks"][0],
        "type": "firm",
        "destAddress": dest_address,  # required for on_ramp; locked onto the quote
        "destNetwork": "arbitrum",
    })

    # Fail-soft: HTTP 200 + available:false => no quoteId. Never transact.
    if not quote.get("available") or not quote.get("executable"):
        return {"state": "unavailable", "reason": quote.get("unavailableReason")}

    # 4. Transact — Idempotency-Key REQUIRED; reuse it (and body) on retry
    idem = str(uuid.uuid4())
    try:
        tx = call("POST", f"/pools/{pool['id']}/transact", SK,
                  headers={"Idempotency-Key": idem},
                  json={"quoteId": quote["quoteId"]})
    except RuntimeError as e:
        if e.args[0].get("statusCode") == 409 and e.args[0].get("code") == "expired":
            return {"state": "expired"}  # quote lapsed — go re-quote
        raise

    # 5. Poll to settlement — this poll ADVANCES the reserved trade
    status = tx["status"]  # 'reserved'
    for _ in range(30):
        if status in ("settled", "failed", "released", "returned"):
            break
        time.sleep(1)
        status = call("GET", f"/pools/transactions/{quote['quoteId']}", SK)["status"]

    return {"state": status, "transactId": tx["transactId"], "quoteId": quote["quoteId"]}

# 6. Reconcile (pure read; does NOT advance settlement)
def reconcile(cursor=None):
    params = {"limit": 50}
    if cursor:
        params["cursor"] = cursor
    return call("GET", "/pools/trades", SK, params=params)

Error handling

Every error uses the unified envelope { type, code, message, request_id, doc_url, statusCode }, and every response carries an X-Request-Id header. Branch on the machine-readable code/type/statusCode, never on the free-form message.

CaseWhereWhat to do
available: falseQuote (HTTP 200)Fail-soft, not an error. There is no quoteId — show an unavailable state, branch on unavailableReason, and back off. Do not transact.
409 expired / consumedTransactThe quote lapsed or was already used. Do not retry transact — lock a fresh quote and start step 3 again.
409 idempotency-conflictTransactAn Idempotency-Key was reused with a different body. Retries must reuse the same key and the same body; use a new key only for a genuinely new write.
403 (branch on code)Any sk_ callpools_not_enabled, pool_access_suspended, kyc_not_approved, pool_not_allowed, or key_mode_mismatch. These are entitlement/access denials — resolve through account review, not in code.
404Any pool callUnknown pool, or a pool not on your allowlist. Cross-tenant access is 404, never 403.
402 insufficient balanceTransactPre-funded balance cannot cover the trade.
429Discovery / transactThrottled (per-IP rate limit, or a per-partner 24h notional velocity cap at transact). Back off and retry.
501 off_ramp not availableTransactThe sell path is gated. Treat off-ramp as forward-looking until your account review enables it.

Stay venue-safe in your own UX

Surface partner-visible facts only. Do not expose liquidity sources, settlement venues or networks, treasury or reserve mechanics, routing internals, or bank/rail provider details in customer-facing screens, logs, or support tooling.

On this page