{"openapi":"3.1.0","info":{"title":"BlankFX Public API","version":"1.0.0","description":"Programmatic, **non-custodial** access to BlankFX: read your account, move money, and trade FX\nacross the AMM (gasless Permit2 relay) and RFQ (MM-backed escrow) rails. BlankFX never holds your\nkeys — every value operation returns EIP-712 typed-data that **you** sign with your registered\nwallet.\n\n## Base URL\n\nAll endpoints are under `https://api.blankfx.org/v1` (phase-1 `bfx_test_` keys run against the\nSepolia sandbox). Errors share one shape: `{ \"error\": { \"code\": \"<slug>\", \"message\": \"...\" } }`.\n\n## Authentication — Ed25519 request signing\n\nThere is **no shared secret**. You generate an Ed25519 keypair, register the *public* key, and\nsign every request with the *private* key (which BlankFX never sees).\n\n1. **Create a key** in the dashboard (Settings → API keys). Your Ed25519 keypair is generated in\n   the browser; the **private key (PKCS8 PEM) is shown once** — store it securely. Only the public\n   key is sent to BlankFX. You choose the key's scopes (below).\n2. **Sign each request.** Build the canonical string (LF-separated, no trailing newline):\n   ```\n   ${timestamp}\\n${METHOD}\\n${requestTarget}\\n${sha256_hex(body)}\n   ```\n   where `timestamp` is unix **seconds**, `METHOD` is upper-case, `requestTarget` is the path\n   **including any query string** (e.g. `/public/v1/fx/orders?limit=10`), and `sha256_hex(body)` is\n   the hex SHA-256 of the raw request body (`sha256_hex(\"\")` for GET/no-body). Then send three\n   headers:\n   - `BFX-API-KEY`: your `key_id`\n   - `BFX-TIMESTAMP`: the same unix-seconds timestamp\n   - `BFX-SIGNATURE`: `base64( Ed25519_sign(privateKey, canonicalString) )`\n3. **Freshness + replay.** The timestamp must be within **±30s** of server time, and each signature\n   is single-use — re-sending an identical signed request is rejected (`replayed`). Always sign\n   retries with a **fresh timestamp** (even for idempotent calls).\n\n### Node signing example\n```js\nimport crypto from 'node:crypto';\nfunction signedHeaders({ keyId, privateKeyPem, method, requestTarget, body = '' }) {\n  const ts = Math.floor(Date.now() / 1000).toString();\n  const bodyHash = crypto.createHash('sha256').update(body).digest('hex');\n  const canonical = `${ts}\\n${method.toUpperCase()}\\n${requestTarget}\\n${bodyHash}`;\n  const sig = crypto.sign(null, Buffer.from(canonical), crypto.createPrivateKey(privateKeyPem));\n  return { 'BFX-API-KEY': keyId, 'BFX-TIMESTAMP': ts, 'BFX-SIGNATURE': sig.toString('base64') };\n}\n```\n\n## Scopes\n\nEach key carries a subset of scopes; a call missing the required scope returns `403\ninsufficient_scope`.\n- `read` — account, balances, wallets, rates, transactions, payment-link reads, exports\n- `trade` — FX quotes, accept, order submit/read (`/fx/*`)\n- `pay` — transfers, payment-link create/cancel, contacts (`/transfers*`, `/payment-links`)\n- `wallets` — programmatic signer-wallet register/manage (`/wallets*`)\n- `webhooks` — webhook endpoint management\n\n## Idempotency\n\nResource-mutating calls — payment-link create/cancel, webhook create, wallet register, contact\ncreate, FX quote accept, transfer create — REQUIRE an `Idempotency-Key` header (any unique string,\ne.g. a UUID); a request without it returns `400 idempotency_key_required`. Replaying the same key on\nthe same route within 24h returns the original response (with `Idempotency-Replayed: true`) instead\nof acting twice. Other write-shaped routes (e.g. `/transfers/:id/submit`, `/webhooks/:id/test`,\n`/fx/quotes`) do not take the header.\n\n## Rate limits\n\nPer-key sliding window: **600/min** read, **120/min** write, **30/min** on the heaviest trade\noperations. Over-limit returns `429`.\n\n## Non-custodial value flow (FX)\n\n1. `POST /fx/quotes` — get indicative AMM + firm RFQ quotes for a pair.\n2. `POST /fx/quotes/{id}/accept` — locks the quote, creates an order, and returns **EIP-712\n   typed-data** to sign (AMM → a Permit2-witness order; RFQ → an escrow-funding order).\n3. Sign the typed-data with your registered wallet.\n4. `POST /fx/orders/{id}/submit` — send `{ signature }` (AMM: the relayer broadcasts your signed\n   Permit2 order) or `{ tx_hash }` (RFQ: you self-broadcast the escrow-funding tx; the backend\n   verifies the on-chain event). Poll `GET /fx/orders/{id}` for `settled`.\n\n## Transfers\n\n- **Cross-currency** (`from_currency ≠ currency`): one atomic swap delivered to the payee — `accept`\n   returns a Permit2-witness order to sign; `submit { signature }` relays it.\n- **Same-currency** (same token): non-custodial self-broadcast — you sign and broadcast a standard\n   ERC-20 transfer, then `submit { tx_hash }` and the backend verifies the on-chain `Transfer`.\n\n## Webhooks\n\nRegister endpoints (scope `webhooks`) to receive `payment_link.paid`, `fx.order.settled|failed|\nrefunded`, and `transfer.confirmed|failed`. Each delivery is HMAC-signed with a per-endpoint secret\nshown once at registration; verify it before trusting the payload.","contact":{"name":"BlankFX","url":"https://blankfx.org"}},"servers":[{"url":"https://api.blankfx.org/v1","description":"Public host (test keys)"}],"security":[{"bfxEd25519":[]}],"tags":[{"name":"Meta","description":"Health + the machine-readable contract (unauthenticated)."},{"name":"Account","description":"Read account, balances, wallets, rates, transactions (scope `read`)."},{"name":"FX","description":"Non-custodial FX trading across AMM + RFQ (scope `trade`)."},{"name":"Transfers","description":"Cross-currency (atomic swap) + same-token (self-broadcast) transfers (scope `pay`)."},{"name":"Payment links","description":"Create/read/cancel payment links (scope `pay`/`read`)."},{"name":"Wallets","description":"Programmatic signer-wallet register/manage via SIWE (scope `wallets`)."},{"name":"Contacts","description":"Saved payout recipients (scope `pay`)."},{"name":"Webhooks","description":"Event delivery endpoints (scope `webhooks`)."}],"components":{"schemas":{"Error":{"type":"object","description":"The single error shape returned by every endpoint.","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string","example":"Requires the 'trade' scope"}},"required":["code","message"]}}},"FxQuote":{"type":"object","properties":{"quote_id":{"type":"string","description":"Opaque id passed to accept. AMM ids are ephemeral; RFQ ids are persisted + firm."},"route":{"type":"string","enum":["amm","rfq"]},"rate":{"type":"string","nullable":true},"sell_amount":{"type":"string"},"buy_amount":{"type":"string","nullable":true},"fees":{"type":"array","items":{"type":"object"}},"expires_at":{"type":"string","format":"date-time","nullable":true},"firm":{"type":"boolean","description":"true for RFQ (MM-backed), false for AMM (indicative pool pricing)."}}},"FxOrder":{"type":"object","properties":{"id":{"type":"string"},"route":{"type":"string","enum":["amm","rfq"]},"state":{"type":"string","enum":["quoted","accepted","awaiting_funding","funded","settling","settled","failed","expired","refunded"]},"sell_asset":{"type":"string","nullable":true},"buy_asset":{"type":"string","nullable":true},"tx_hash":{"type":"string","nullable":true},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}},"Transfer":{"type":"object","properties":{"id":{"type":"string"},"kind":{"type":"string","enum":["swap","self_transfer"],"description":"swap = cross-currency (relayed); self_transfer = same-token (client self-broadcast)."},"from_currency":{"type":"string"},"currency":{"type":"string"},"amount":{"type":"string"},"recipient":{"type":"string"},"state":{"type":"string","enum":["created","awaiting_signature","broadcasting","confirmed","failed"]},"tx_hash":{"type":"string","nullable":true},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}},"SigningPayload":{"type":"object","description":"EIP-712 typed-data to sign with your registered wallet. Never contains a private key; signing it is what authorises the value movement.","properties":{"type":{"type":"string","enum":["permit2_witness","rfq_escrow_funding"]},"order":{"type":"object","description":"The GaslessOrder struct (permit2_witness) to sign."},"funding":{"type":"object","description":"The RfqEscrowFunding struct (rfq_escrow_funding) to sign."},"relay":{"type":"object","description":"Relay params: permit2, relayRouter/escrow, witnessTypeString, chainId."}}}},"securitySchemes":{"bfxEd25519":{"type":"apiKey","in":"header","name":"BFX-API-KEY","description":"Ed25519 request signing. Send three headers: BFX-API-KEY (your key_id), BFX-TIMESTAMP (unix seconds; rejected if more than 30s from server time), and BFX-SIGNATURE = base64(Ed25519_sign(privateKey, `${timestamp}\\n${METHOD}\\n${requestTarget}\\n${sha256(body)}`)) where requestTarget is the path including any query string. The server verifies the signature against your registered public key; no shared secret exists."}}},"paths":{"/status":{"get":{"summary":"Service health (public, unauthenticated)","security":[],"responses":{"200":{"description":"Service is healthy"}}}},"/whoami":{"get":{"summary":"Resolve the calling key — its account and granted scopes","responses":{"200":{"description":"The resolved key identity (key_id, user_id, scopes)"},"401":{"description":"Missing/invalid signature, stale timestamp, replay, or unknown key"},"403":{"description":"Insufficient scope or IP not allowed"}}}},"/account":{"get":{"summary":"Account profile, KYC/compliance status, wallets","responses":{"200":{"description":"Account"},"401":{"description":"Auth failed"},"403":{"description":"Insufficient scope"}}}},"/balances":{"get":{"summary":"Consolidated on-chain balances by currency hub + per wallet","responses":{"200":{"description":"Balances"},"401":{"description":"Auth failed"},"403":{"description":"Insufficient scope"},"502":{"description":"Balance RPC unavailable"}}}},"/wallets":{"get":{"summary":"List the account’s linked wallets (read-only)","responses":{"200":{"description":"Wallets"},"401":{"description":"Auth failed"},"403":{"description":"Insufficient scope"}}},"post":{"summary":"Register a signing wallet via SIWE proof (wallets scope)","responses":{"201":{"description":"Linked"},"401":{"description":"SIWE failed"},"403":{"description":"Insufficient scope"},"409":{"description":"Owned by another account"}}}},"/wallets/siwe-nonce":{"get":{"summary":"Get a SIWE nonce for programmatic wallet proof (wallets scope)","responses":{"200":{"description":"Nonce"},"403":{"description":"Insufficient scope"}}}},"/wallets/{address}":{"patch":{"summary":"Relabel / set-primary a wallet (wallets scope)","responses":{"200":{"description":"Updated"},"403":{"description":"Insufficient scope"},"404":{"description":"Not found"}}},"delete":{"summary":"Unlink a wallet (cannot remove the last signer) (wallets scope)","responses":{"200":{"description":"Removed"},"400":{"description":"Last signer"},"403":{"description":"Insufficient scope"},"404":{"description":"Not found"}}}},"/wallets/{address}/promote":{"post":{"summary":"Promote a watch-only wallet to signing via SIWE proof (wallets scope)","responses":{"200":{"description":"Promoted"},"400":{"description":"Mismatch/denied"},"403":{"description":"Insufficient scope"}}}},"/contacts":{"get":{"summary":"List saved contacts (pay scope)","responses":{"200":{"description":"Contacts"},"403":{"description":"Insufficient scope"}}},"post":{"summary":"Create a contact (pay scope)","responses":{"201":{"description":"Created"},"400":{"description":"Invalid"},"403":{"description":"Insufficient scope"},"409":{"description":"Duplicate"}}}},"/contacts/{id}":{"delete":{"summary":"Delete a contact (pay scope)","responses":{"200":{"description":"Deleted"},"403":{"description":"Insufficient scope"},"404":{"description":"Not found"}}}},"/transactions/export":{"get":{"summary":"Export the transaction ledger as CSV (read scope)","responses":{"200":{"description":"CSV"},"401":{"description":"Auth failed"},"403":{"description":"Insufficient scope"}}}},"/fx/rates":{"get":{"summary":"FX daily-open baseline rates per pair","responses":{"200":{"description":"Rates"},"401":{"description":"Auth failed"},"403":{"description":"Insufficient scope"}}}},"/transactions":{"get":{"summary":"Unified transaction ledger (FX swaps, RFQ, payment links), newest first","responses":{"200":{"description":"Transactions"},"401":{"description":"Auth failed"},"403":{"description":"Insufficient scope"}}}},"/payment-links":{"get":{"summary":"List payment links created by the account","responses":{"200":{"description":"Payment links"},"401":{"description":"Auth failed"},"403":{"description":"Insufficient scope"}}},"post":{"summary":"Create a payment link (pay scope, idempotent)","responses":{"201":{"description":"Created"},"400":{"description":"Invalid"},"401":{"description":"Auth failed"},"403":{"description":"Insufficient scope"}}}},"/payment-links/{id}":{"get":{"summary":"Read/validate a single payment link","responses":{"200":{"description":"Payment link"},"401":{"description":"Auth failed"},"403":{"description":"Insufficient scope"},"404":{"description":"Not found"}}},"patch":{"summary":"Cancel a payment link (pay scope) — only a pending link is cancellable","responses":{"200":{"description":"Cancelled (repeat cancel is an idempotent no-op)"},"401":{"description":"Auth failed"},"403":{"description":"Insufficient scope"},"404":{"description":"Not found"},"409":{"description":"Already paid or expired — cannot be cancelled"}}}},"/currencies":{"get":{"summary":"Supported tokens/currencies + chain","responses":{"200":{"description":"Currencies"},"401":{"description":"Auth failed"},"403":{"description":"Insufficient scope"}}}},"/fx/quotes":{"post":{"summary":"Unified indicative-AMM + firm-RFQ quotes for a pair (trade scope)","responses":{"200":{"description":"Quotes"},"400":{"description":"Invalid request"},"401":{"description":"Auth failed"},"403":{"description":"Insufficient scope"},"502":{"description":"No quote available"}}}},"/fx/quotes/{id}/accept":{"post":{"summary":"Accept a quote → create an order + return the signable EIP-712 payload (trade scope, idempotent)","responses":{"201":{"description":"Accepted"},"401":{"description":"Auth failed"},"403":{"description":"Insufficient scope / gated"},"404":{"description":"Quote not found"}}}},"/fx/orders/{id}/submit":{"post":{"summary":"Submit a signed order — {signature} relays (AMM) or {tx_hash} verifies escrow (RFQ) (trade scope)","responses":{"200":{"description":"Submitted"},"400":{"description":"Invalid"},"401":{"description":"Auth failed"},"403":{"description":"Insufficient scope"},"404":{"description":"Order not found"}}}},"/fx/orders":{"get":{"summary":"List FX orders (AMM + RFQ), account-scoped, newest first (trade scope)","responses":{"200":{"description":"Orders"},"401":{"description":"Auth failed"},"403":{"description":"Insufficient scope"}}}},"/fx/orders/{id}":{"get":{"summary":"Read a single FX order (trade scope)","responses":{"200":{"description":"Order"},"401":{"description":"Auth failed"},"403":{"description":"Insufficient scope"},"404":{"description":"Not found"}}}},"/transfers":{"get":{"summary":"List transfers, account-scoped (pay scope)","responses":{"200":{"description":"Transfers"},"401":{"description":"Auth failed"},"403":{"description":"Insufficient scope"}}},"post":{"summary":"Create a transfer — cross-currency (atomic swap) or same-token (self-broadcast) (pay scope, idempotent)","responses":{"201":{"description":"Created"},"400":{"description":"Invalid"},"401":{"description":"Auth failed"},"403":{"description":"Insufficient scope / gated"}}}},"/transfers/{id}/submit":{"post":{"summary":"Submit a transfer — {signature} relays the swap or {tx_hash} verifies a self-broadcast (pay scope)","responses":{"200":{"description":"Submitted"},"400":{"description":"Invalid"},"401":{"description":"Auth failed"},"403":{"description":"Insufficient scope"},"404":{"description":"Not found"},"422":{"description":"Transfer not verified on-chain"}}}},"/transfers/{id}":{"get":{"summary":"Read a single transfer (pay scope)","responses":{"200":{"description":"Transfer"},"401":{"description":"Auth failed"},"403":{"description":"Insufficient scope"},"404":{"description":"Not found"}}}},"/webhooks":{"get":{"summary":"List webhook endpoints","responses":{"200":{"description":"Endpoints"},"401":{"description":"Auth failed"},"403":{"description":"Insufficient scope"}}},"post":{"summary":"Register a webhook endpoint (returns the signing secret once)","responses":{"201":{"description":"Created"},"400":{"description":"Invalid URL/events"},"403":{"description":"Insufficient scope"},"409":{"description":"Endpoint cap reached"},"503":{"description":"Webhooks not configured"}}}},"/webhooks/{id}":{"delete":{"summary":"Delete a webhook endpoint","responses":{"200":{"description":"Deleted"},"401":{"description":"Auth failed"},"403":{"description":"Insufficient scope"},"404":{"description":"Not found"}}}},"/webhooks/{id}/test":{"post":{"summary":"Send a test delivery to a webhook endpoint","responses":{"200":{"description":"Sent"},"403":{"description":"Insufficient scope"},"404":{"description":"Not found"},"503":{"description":"Webhooks not configured"}}}}}}