# Fiatsend Partner API — full docs for LLMs > Generated 2026-10-09 from developer.fiatsend.com and fiatsend-openapi.yaml v1.3.0, checked against the live sandbox. # Fiatsend Partner API Fiatsend converts USDC/USDT to Ghana cedis (GHS) and delivers them to mobile money wallets, and lets businesses take payments through hosted checkout. This skill is the working reference for building against the **Partner API v1** (spec version 1.3.0). Canonical sources (check these when in doubt — they win over this file): - Docs: https://developer.fiatsend.com - OpenAPI 3.0: https://developer.fiatsend.com/fiatsend-openapi.yaml - Postman: https://developer.fiatsend.com/fiatsend-postman-collection.json - Keys and webhooks UI: https://console.fiatsend.com (Developers → API Keys / Webhooks) - Support: partners@fiatsend.com ## Ground rules (always apply) 1. **Server-side only.** API keys never go in browser, mobile or any client bundle. Read them from `FIATSEND_API_KEY`. 2. **Sandbox first.** Default to `https://sandbox.fiatsend.com/v1` with an `fs_test_` key. Only use `https://api.fiatsend.com/v1` with an `fs_live_` key when the user explicitly says production. - Exception: **checkout sessions** use `https://api.fiatsend.com/v1` for both modes; the key prefix decides test vs live. 3. **Money is a string.** Withdrawal and rate amounts are decimal strings (`"50.00"`). Never use floats for arithmetic — use integer minor units or a decimal library. (Checkout `amount` is a JSON number in major units.) 4. **Idempotency = `reference_id`.** Every withdrawal needs a unique, stable `reference_id` derived from your own record (e.g. `payout_`). Generate it once, persist it, and reuse it on retry. Re-posting the same `reference_id` returns the existing withdrawal instead of creating a second one (confirmed in sandbox, 2026-10-09). Never generate a fresh random ID inside a retry loop. 5. **Phones are E.164 Ghana numbers**: `+233` followed by 9 digits (e.g. `+233241234567`). Normalise `0241234567` → `+233241234567` before sending. 6. **Confirm payment on the server.** Fulfil orders only after the `checkout.session.completed` webhook or a server-side `GET` shows `status: "complete"` — never on a browser redirect or JS event. 7. **Verify every webhook** with HMAC-SHA256 over the **raw** body before parsing (see `references/webhooks.md`). 8. **Never move real money from an AI tool without explicit human confirmation** of amount, recipient phone and network. ## Authentication ```http Authorization: Bearer fs_test_xxx # sandbox Authorization: Bearer fs_live_xxx # production Content-Type: application/json ``` `GET /v1/health` needs no auth. The public demo key `fs_test_demo_key_2026` works for sandbox "Try it" calls but **not** for checkout sessions. ## Endpoint map | Goal | Method & path | Notes | |---|---|---| | Health check | `GET /health` | No auth | | Networks + min/max | `GET /supported-networks` | `status`: operational / degraded / down | | KYC tier limits | `GET /limits` | basic / standard / enterprise | | FX quote | `GET /rates?from_currency=USDC&to_currency=GHS&amount=100.00` | Guaranteed until `valid_until`. `fee` is in the stablecoin: `total_ghs = (amount − fee) × rate` | | Send payout | `POST /withdrawals` | Returns 201, `status: pending` | | Payout status | `GET /withdrawals/{withdrawal_id}` | Prefer webhooks over polling | | List payouts | `GET /transactions` | Filters: status, reference_id, page, per_page≤100 | | Create checkout | `POST /checkout/sessions` | Redirect customer to `url` | | Get checkout | `GET /checkout/sessions/{id}` | `status`: open / complete / cancelled | | List checkouts | `GET /checkout/sessions?limit=25` | 1–100 | | Register webhook | `POST /webhooks` | Withdrawal events only | | List / delete webhooks | `GET /webhooks`, `DELETE /webhooks/{id}` | Delete returns 204 | | Payment intents (beta) | `POST /payment-intents`, `GET /payment-intents/{id}`, `POST /payment-intents/{id}/cancel` | Customer approves in the Fiatsend app or pays the USDC link with a Stellar wallet; events `payment_intent.*`; fields may still change | Full request/response shapes: `references/api-reference.md`. ## The payout flow (most common task) ``` GET /supported-networks → is the network operational? is the amount within min/max? GET /rates → show recipient the GHS they'll get; note valid_until POST /withdrawals → persist withdrawal_id + reference_id, status=pending webhook (or poll GET) → pending → processing → completed | failed ``` Minimal Node.js (18+, no SDK needed): ```js const BASE = process.env.FIATSEND_BASE_URL ?? "https://sandbox.fiatsend.com/v1"; async function fiatsend(path, init = {}) { const res = await fetch(`${BASE}${path}`, { ...init, headers: { Authorization: `Bearer ${process.env.FIATSEND_API_KEY}`, "Content-Type": "application/json", ...init.headers, }, }); const body = res.status === 204 ? null : await res.json(); if (!res.ok) { const code = body?.code ?? body?.error?.code ?? res.status; const err = new Error(`Fiatsend ${code}: ${body?.message ?? body?.error?.message}`); err.code = code; err.status = res.status; throw err; } return body; } // payout.id comes from YOUR database — that makes reference_id stable across retries export async function sendPayout(payout) { const { data } = await fiatsend("/withdrawals", { method: "POST", body: JSON.stringify({ amount: payout.amount, // "50.00" currency: "USDC", // or "USDT" recipient_phone: payout.phone, // "+233241234567" mobile_network: payout.network, // "MTN" | "TELECEL" | "AIRTELTIGO" reference_id: `payout_${payout.id}`, metadata: { user_id: String(payout.userId) }, }), }); return data; // { withdrawal_id, status: "pending", ghs_amount, fx_rate, fee, ... } } ``` Python equivalent and checkout examples: `examples/`. ## Errors and retries Two error shapes exist — handle both: - Most endpoints: `{"status":"error","code":"INVALID_PHONE","message":"..."}` - Checkout endpoints: `{"error":{"code":"invalid_request","message":"..."}}`; a 401 may be just `{"message":"..."}`. | Retry? | Codes | |---|---| | Fix the request, don't retry | `UNAUTHORIZED` 401, `INVALID_PHONE` 400, `INVALID_NETWORK` 400, `BELOW_MINIMUM` 422, `ABOVE_MAXIMUM` 422, `invalid_request`, `account_not_active` 403, `account_not_ready` 422, `not_found` 404 | | Re-quote, then retry | `RATE_EXPIRED` 422 | | Retry with backoff, **same reference_id** | `RATE_LIMITED` / `rate_limited` 429, `NETWORK_DOWN` 503, `INSUFFICIENT_LIQUIDITY` 503, `INTERNAL_ERROR` 500, `upstream_unavailable` 502, timeouts | On a timeout after `POST /withdrawals`, retry the **same request with the same `reference_id`**: the API returns the existing withdrawal rather than creating a new one. Don't use `GET /transactions?reference_id=` to check — that filter is currently ignored and returns other payouts. Rate limits: sandbox 60/min · 10k/day; production 300/min · 100k/day; checkout 120/min per business. ## Webhooks (summary) - Header `X-Fiatsend-Signature` = HMAC-SHA256 of the raw body with your webhook secret, as `sha256=` on withdrawal and payment-intent events and bare hex on checkout events. Strip the `sha256=` prefix, then compare in constant time. - **Withdrawal events** (`withdrawal.pending|processing|completed|failed`): event name in `type`, payload in `data`. Registered via `POST /v1/webhooks` (you supply the `secret`). Retries: 1m, 5m, 30m, 2h, 24h. - **Checkout event** (`checkout.session.completed`): event name in `event`, session in `data.object`, unique `id`. Registered in Console. Up to 3 attempts, 8 s timeout each. - Read the name with `payload.type ?? payload.event`. Return 2xx fast, process async, and de-duplicate (event `id` for checkout; `withdrawal_id` + `status` for withdrawals). Full handler code (Express, Next.js, FastAPI): `references/webhooks.md`. ## Testing checklist before go-live - [ ] Health, networks, rates and a sandbox withdrawal all succeed with an `fs_test_` key - [ ] Webhook endpoint rejects a bad signature with 401 and accepts a good one - [ ] Duplicate webhook deliveries don't double-credit - [ ] Retrying a withdrawal after a simulated timeout doesn't create a second payout - [ ] `failed` withdrawals surface `failure_reason` to ops / the user - [ ] Keys only in server env vars; `.env` is git-ignored - [ ] Live key swap is a config change only (`FIATSEND_BASE_URL`, `FIATSEND_API_KEY`) ## Reference files - `references/api-reference.md` — every endpoint, field and response - `references/webhooks.md` — signature verification and handlers in 3 frameworks - `examples/payout.py`, `examples/checkout.js` — runnable starter code # Fiatsend Partner API v1 — full reference Source: https://developer.fiatsend.com and `fiatsend-openapi.yaml` (v1.3.0), captured 2026-10-09. Base URLs - Sandbox: `https://sandbox.fiatsend.com/v1` - Production: `https://api.fiatsend.com/v1` - Checkout sessions: `https://api.fiatsend.com/v1` for both test (`fs_test_`) and live (`fs_live_`) keys Auth: `Authorization: Bearer ` on everything except `/health`. --- ## Withdrawals (payouts) ### POST /withdrawals Locks the FX rate, converts stablecoin → GHS, sends to the mobile wallet. | Field | Type | Req | Notes | |---|---|---|---| | amount | string | ✓ | Source-currency amount, e.g. `"50.00"` | | currency | string | ✓ | `USDC` \| `USDT` | | recipient_phone | string | ✓ | E.164, Ghana: `+233XXXXXXXXX` | | mobile_network | string | ✓ | `MTN` \| `TELECEL` \| `AIRTELTIGO` | | reference_id | string | ✓ | Your unique ID; acts as idempotency key | | metadata | object | | Free-form key/values | 201: ```json { "status": "success", "data": { "withdrawal_id": "wd_9f8e7d6c5b4a", "status": "pending", "amount": "50.00", "currency": "USDC", "ghs_amount": "750.00", "fx_rate": "15.00", "fee": "0.50", "recipient_phone": "+233241234567", "mobile_network": "MTN", "reference_id": "acme-txn-abc123", "estimated_completion": "2026-03-21T07:00:00Z", "created_at": "2026-03-21T06:59:30Z" } } ``` Errors: 400 `INVALID_PHONE`/`INVALID_NETWORK`, 401, 422 `BELOW_MINIMUM`/`ABOVE_MAXIMUM`/`RATE_EXPIRED`, 429, 503. Duplicate `reference_id`: returns success with the **existing** withdrawal (same `withdrawal_id`, current status) — no second payout. Verified against the sandbox on 2026-10-09. Safe to retry after a timeout. Status lifecycle: `pending` → `processing` → `completed` | `failed` (funds returned to source). ### GET /withdrawals/{withdrawal_id} 200 adds: `on_chain_tx_hash`, `mobile_money_reference`, `processing_at`, `completed_at`, `failed_at`, `failure_reason`. 404 if unknown. ### GET /transactions Query: `status`, `from_date`, `to_date` (ISO 8601), `reference_id`, `page` (1), `per_page` (25, max 100). Response: `{ status, data: [WithdrawalDetail], pagination: { page, per_page, total, total_pages } }`. --- ## Rates and networks ### GET /rates Query (all required): `from_currency` (`USDC`|`USDT`), `to_currency` (`GHS`), `amount` (string). ```json { "status": "success", "data": { "from_currency": "USDC", "to_currency": "GHS", "amount": "100.00", "rate": "15.02", "fee": "1.00", "total_ghs": "1501.00", "valid_until": "2026-03-21T07:05:00Z" } } ``` Rate is guaranteed until `valid_until`. `fee` is in the source stablecoin: `total_ghs = (amount − fee) × rate`, rounded to 2 places (sandbox: 10.00 USDC, fee 0.10, rate 11.65 → 115.34). Example values are illustrative — always use the live response. ### GET /supported-networks Each item: `network_id`, `name`, `country` (`GH`), `currency` (`GHS`), `status` (`operational`|`degraded`|`down`), `min_amount`, `max_amount`. `min_amount`/`max_amount` are in GHS (the network `currency`), not the stablecoin: every payout must deliver GHS 50.00 to 15,000.00 (`ghs_amount`), otherwise `BELOW_MINIMUM` / `ABOVE_MAXIMUM`. Documented examples: MTN 5–10,000; TELECEL 5–5,000; AIRTELTIGO 5–5,000. Read live values; don't hard-code. ### GET /limits Per KYC tier: `tier`, `daily_limit`, `monthly_limit`, `per_transaction_max`. Tiers returned by the API: `basic`, `standard`, `enterprise` (the docs call the middle tier `verified`). --- ## Checkout sessions (accept payments) ### POST /checkout/sessions | Field | Type | Req | Notes | |---|---|---|---| | amount | number | ✓ | Major units, > 0 | | currency | string | | `GHS` (default) \| `USDC` | | description | string | | ≤200 chars, shown to customer | | client_reference_id | string | | ≤100 chars, your order ID | | customer | object | | `name`, `email`, `phone` (E.164); email → receipt | | success_url / cancel_url | string | | https only (`http://localhost` OK in test). `{SESSION_ID}` placeholder is replaced; otherwise `session_id` query param is appended | | metadata | object | | ≤20 string pairs, keys ≤40, values ≤500 | | accepted_methods | string[] | | `fiatsend`, `mobile_money` (GHS only), `bank_transfer` | 201 → session object. Send the customer to `url`. ### GET /checkout/sessions/{session_id} `session_id` pattern `^cs_[a-f0-9]{48}$`. `status: "complete"` = paid. ### GET /checkout/sessions?limit=N `{ "object": "list", "data": [session, ...] }`, newest first, same mode as the key. Session object: `id`, `object` (`checkout.session`), `status` (`open`|`complete`|`cancelled`), `url`, `amount`, `currency`, `description`, `client_reference_id`, `metadata`, `customer`, `accepted_methods`, `success_url`, `cancel_url`, `paid_at`, `paid_via` (`fiatsend`|`mobile_money`|`bank_transfer`|`manual`), `livemode`, `created_at`. Checkout errors: `{"error":{"code","message"}}` — `invalid_request` 400, 401 (`{"message"}`), `account_not_active` 403 (live key before activation), `not_found` 404, `account_not_ready` 422 (e.g. no Stellar wallet connected), `rate_limited` 429, `upstream_unavailable` 502. Limit: 120 req/min per business. Embedded popup instead of redirect: ```html ``` No-code buttons: https://docs.fiatsend.com/docs/payments/website-checkout --- ## Payment intents (beta) Beta: works in sandbox and production, but fields and events may still change. The customer approves in the Fiatsend app or pays the USDC link with a Stellar wallet (USDC intents pay straight to the business's bound Stellar wallet). Webhook events: `payment_intent.pending_approval`, `.approved`, `.completed`, `.rejected`, `.cancelled`, `.expired`, `.failed`. Each business only sees its own intents. - `POST /payment-intents` — `amount` (string), `currency` (usually `GHS`), `consumer_phone` (E.164), `merchant_reference` (idempotency), optional `terminal_id`, `description`. 201 → `payment_intent_id`, `status: "pending_approval"`, `expires_at` (1 hour after creation by default). - `GET /payment-intents/{id}` - `POST /payment-intents/{id}/cancel` - `/internal/payment-intents/*` routes use `X-Internal-Token` and are for Fiatsend's own services — partners should not call them. --- ## Webhook endpoints - `POST /webhooks` — `url` (uri), `events` (subset of `withdrawal.pending|processing|completed|failed`), `secret`. 201 → `webhook_id`, `active`, `created_at`. - `GET /webhooks` — list. - `DELETE /webhooks/{webhook_id}` — 204. Checkout webhooks are configured in Console, not here. ## Health `GET /health` (no auth) → `status`, `version`, `uptime_seconds`, `environment`, `services.{blockchain,mobile_money,database}`. ## Error codes (non-checkout) | Code | HTTP | Meaning | |---|---|---| | UNAUTHORIZED | 401 | Invalid or missing API key | | INVALID_PHONE | 400 | Bad phone format | | INVALID_NETWORK | 400 | Unsupported network | | BELOW_MINIMUM | 422 | Payout below GHS 50 (the cedis delivered) | | ABOVE_MAXIMUM | 422 | Payout above GHS 15,000 (the cedis delivered) | | REFERENCE_IN_USE | 409 | `reference_id` already used by another account; use a unique one per payout | | INSUFFICIENT_BALANCE | 422 | Business balance doesn't cover the withdrawal; fund it in the console | | PAYOUTS_UNAVAILABLE | 503 | Payouts temporarily unavailable; nothing was charged, retry later | | RATE_EXPIRED | 422 | Quote expired | | NETWORK_DOWN | 503 | Mobile money network unavailable | | INSUFFICIENT_LIQUIDITY | 503 | Not enough liquidity | | RATE_LIMITED | 429 | Too many requests | | INTERNAL_ERROR | 500 | Server error | ## Rate limits | | per minute | per day | |---|---|---| | Sandbox | 60 | 10,000 | | Production | 300 | 100,000 | | Checkout (any) | 120 per business | — | # Fiatsend webhooks — verification and handlers ## The rules 1. Read the **raw** request body (bytes). Parsing JSON first and re-serialising changes the bytes and breaks the signature. 2. `X-Fiatsend-Signature` = lowercase hex of `HMAC_SHA256(secret, raw_body)`. 3. Compare in constant time, and check lengths first (Node's `timingSafeEqual` throws on unequal lengths). 4. Respond `2xx` quickly (checkout times out at 8 s, withdrawals at 30 s); do slow work in a queue. 5. De-duplicate. Delivery is at-least-once. ## Two payload shapes Withdrawal events — name in `type`: ```json { "type": "withdrawal.completed", "created_at": "2026-03-21T06:59:42Z", "data": { "withdrawal_id": "wd_...", "status": "completed", "reference_id": "...", "...": "..." } } ``` Checkout event — name in `event`, object in `data.object`, also header `X-Fiatsend-Event`: ```json { "id": "evt_...", "event": "checkout.session.completed", "sentAt": "...", "livemode": true, "data": { "object": { "id": "cs_...", "status": "complete", "client_reference_id": "order_1042" } } } ``` Dedup key: checkout → `id`; withdrawal → `${data.withdrawal_id}:${data.status}`. ## Express (Node.js) ```js import express from "express"; import crypto from "node:crypto"; const app = express(); function isValidSignature(rawBody, header, secret) { if (!header) return false; // Withdrawal events send "sha256="; checkout events send bare hex. Accept both. const received = header.startsWith("sha256=") ? header.slice(7) : header; const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex"); const a = Buffer.from(received, "hex"); const b = Buffer.from(expected, "hex"); return a.length === b.length && crypto.timingSafeEqual(a, b); } // express.raw MUST be on this route, before any express.json() app.post("/webhooks/fiatsend", express.raw({ type: "application/json" }), async (req, res) => { if (!isValidSignature(req.body, req.get("x-fiatsend-signature"), process.env.FIATSEND_WEBHOOK_SECRET)) { return res.status(401).json({ error: "invalid signature" }); } const payload = JSON.parse(req.body.toString("utf8")); const name = payload.type ?? payload.event; const dedupKey = payload.id ?? `${payload.data?.withdrawal_id}:${payload.data?.status}`; if (await alreadyProcessed(dedupKey)) return res.sendStatus(200); switch (name) { case "withdrawal.processing": await markPayout(payload.data.reference_id, "processing"); break; case "withdrawal.completed": await markPayout(payload.data.reference_id, "completed", payload.data); break; case "withdrawal.failed": await markPayout(payload.data.reference_id, "failed", payload.data); break; case "checkout.session.completed": await fulfilOrder(payload.data.object.client_reference_id, payload.data.object); break; } await rememberProcessed(dedupKey); res.sendStatus(200); }); ``` ## Next.js App Router ```ts // app/api/webhooks/fiatsend/route.ts import crypto from "node:crypto"; export async function POST(req: Request) { const raw = await req.text(); // raw body, not req.json() const header = req.headers.get("x-fiatsend-signature") ?? ""; const sig = header.startsWith("sha256=") ? header.slice(7) : header; // withdrawal events are prefixed const expected = crypto.createHmac("sha256", process.env.FIATSEND_WEBHOOK_SECRET!).update(raw).digest("hex"); const ok = sig.length === expected.length && crypto.timingSafeEqual(Buffer.from(sig, "hex"), Buffer.from(expected, "hex")); if (!ok) return new Response("invalid signature", { status: 401 }); const payload = JSON.parse(raw); const name = payload.type ?? payload.event; // ...dedupe + handle as above return new Response(null, { status: 200 }); } ``` ## FastAPI (Python) ```python import hashlib, hmac, json, os from fastapi import FastAPI, Request, HTTPException app = FastAPI() SECRET = os.environ["FIATSEND_WEBHOOK_SECRET"].encode() @app.post("/webhooks/fiatsend") async def fiatsend_webhook(request: Request): raw = await request.body() sig = request.headers.get("x-fiatsend-signature", "") sig = sig[7:] if sig.startswith("sha256=") else sig # withdrawal events are prefixed expected = hmac.new(SECRET, raw, hashlib.sha256).hexdigest() if not hmac.compare_digest(sig, expected): raise HTTPException(status_code=401, detail="invalid signature") payload = json.loads(raw) name = payload.get("type") or payload.get("event") data = payload.get("data", {}) dedup = payload.get("id") or f'{data.get("withdrawal_id")}:{data.get("status")}' # ...dedupe + handle return {"received": True} ``` ## Local testing Sign a fake payload yourself and post it: ```bash SECRET=whsec_test BODY='{"type":"withdrawal.completed","data":{"withdrawal_id":"wd_test","status":"completed","reference_id":"payout_1"}}' SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //') curl -X POST http://localhost:3000/webhooks/fiatsend \ -H "Content-Type: application/json" -H "X-Fiatsend-Signature: $SIG" -d "$BODY" ``` To receive real sandbox webhooks locally, expose your port with a tunnel (e.g. `cloudflared tunnel --url http://localhost:3000` or ngrok) and register the https URL.