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. Publishablepk_keys are limited to pool discovery; every other call below requires a secret key and must run from your backend. Never ship ansk_key to a browser or mobile client. - An entitled pool. Cross-tenant access resolves as
404, never403, 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.
2. Read capabilities (recommended)
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, andpool.transaction.released. Verify theGate-Signatureheader over the raw body, dedupe oneventId(=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 areturnedtrade.
# 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.
| Case | Where | What to do |
|---|---|---|
available: false | Quote (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 / consumed | Transact | The quote lapsed or was already used. Do not retry transact — lock a fresh quote and start step 3 again. |
409 idempotency-conflict | Transact | An 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_ call | pools_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. |
404 | Any pool call | Unknown pool, or a pool not on your allowlist. Cross-tenant access is 404, never 403. |
402 insufficient balance | Transact | Pre-funded balance cannot cover the trade. |
429 | Discovery / transact | Throttled (per-IP rate limit, or a per-partner 24h notional velocity cap at transact). Back off and retry. |
501 off_ramp not available | Transact | The 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.
Related pages
Discover entitled pools
Step 1 in depth: scope discovery to your entitlement.
Lock a pool quote
Step 3 in depth: firm vs indicative and the fail-soft contract.
Execute against a pool quote
Step 4 in depth: idempotent, single-use execution.
Track pool trades
Steps 5–6 in depth: status, reconciliation, and support records.
Check pool balances and availability
Step 2 context: partner-safe availability without liquidity internals.
List entitled pools (API)
GET /pools request and response contract.
Read pool capabilities (API)
GET /pools/{id}/capabilities networks, fees, and limits.
Create a pool quote (API)
POST /pools/{id}/quote firm/indicative and the on-ramp delivery contract.
Execute against a quote (API)
POST /pools/{id}/transact idempotency and status map.
Get transaction status (API)
GET /pools/transactions/{quoteId} — the poll that advances settlement.
0Pools webhooks (API)
Terminal settlement events and signature verification.