Crypto in, money in the bank
Your customer pays in crypto. Xfiat converts it and pays the amount into a bank account. You never hold crypto and never touch exchange rates.
No code
Share a payment link or a button. The payer opens the Xfiat page, picks a coin and scans. Money goes to your registered bank.
Full control
Use the API from your server: your own page, any bank account per payment, webhooks for every change.
Today the API does one thing: crypto to bank. Payouts: NGN (more currencies when enabled for your account).
Coins: USDT, USDC, BTC, ETH and BNB are received directly. Many other coins (for example LTC, SOL, DOGE, XRP) are accepted too: Xfiat converts them on the way in and pays the bank the same way. GET /assets lists them all in one list.
Base address: https://xfiat.app/api/v1
Quick start
- Ask Xfiat for an account. You get test keys straight away.
- Make a test payment (below). Nothing real moves in test mode.
- Move it to
completedwith simulate and watch your webhook receive the events. - When it works, ask for live keys and change the key. Nothing else changes.
curl https://xfiat.app/api/v1/payments \
-H "Authorization: Bearer sk_test_..." \
-H "Idempotency-Key: order-1001" \
-H "Content-Type: application/json" \
-d '{"coin":"USDT","payout_currency":"NGN","payout_amount":"50000",
"bank":{"bank_code":"058","account_number":"0123456789"},
"reference":"ORDER-1001"}'
$ch = curl_init('https://xfiat.app/api/v1/payments');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('XFIAT_SECRET_KEY'),
'Idempotency-Key: order-1001',
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'coin' => 'USDT', 'payout_currency' => 'NGN', 'payout_amount' => '50000',
'bank' => ['bank_code' => '058', 'account_number' => '0123456789'],
'reference' => 'ORDER-1001',
]),
]);
$res = json_decode(curl_exec($ch), true);
$payment = $res['data']; // show $payment['deposit_address'] and $payment['crypto_amount']
const res = await fetch('https://xfiat.app/api/v1/payments', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.XFIAT_SECRET_KEY}`,
'Idempotency-Key': 'order-1001',
'Content-Type': 'application/json',
},
body: JSON.stringify({
coin: 'USDT', payout_currency: 'NGN', payout_amount: '50000',
bank: { bank_code: '058', account_number: '0123456789' },
reference: 'ORDER-1001',
}),
});
const { data: payment } = await res.json(); // payment.deposit_address, payment.crypto_amountShow the payer deposit_address, crypto_amount, coin and network_name. Then wait for the webhook.
Payment links
For a shop, an invoice or a social page. No server needed. Put your public key (pk_live_…) and the amount in a link:
https://xfiat.app/api/v1/pay/?key=pk_live_...&amount=50000&reference=INV-2041
| Parameter | Meaning | |
|---|---|---|
key | required | Your public key. Safe to share. It can only open payment pages. |
amount | required | What your bank should receive, e.g. 50000 (NGN). The payer sees the matching crypto amount. |
reference | optional | Your invoice or order number. One page per reference: opening the link again shows the same page, and once paid it says so. |
description | optional | Short text shown under the amount. |
coins | optional | Limit the choice, e.g. USDT,BTC,LTC. Leave out to offer every coin (the payer can search). |
success_url, cancel_url | optional | Where the payer goes after paying, or when they cancel. |
As a button on your website:
<a href="https://xfiat.app/api/v1/pay/?key=pk_live_...&amount=50000&reference=INV-2041" style="display:inline-block;padding:12px 20px;background:#1d4ed8;color:#fff;border-radius:8px;text-decoration:none">Pay with crypto</a>
Hosted payment page
Create the page from your server with POST /checkouts and send the payer to its url. Xfiat handles the coin choice, the QR code, the countdown and the status.
Keys
| Key | Use |
|---|---|
sk_test_… | Secret key, test mode. Server only. |
sk_live_… | Secret key, real money. Server only. Never in a browser, an app or a public repository. |
pk_test_… pk_live_… | Public key. Only opens payment links. Cannot read or create payments. |
Send the secret key in every request:
Authorization: Bearer sk_live_...
Keys are shown once when Xfiat creates them. If one leaks, ask Xfiat to replace it; the old key stops at once. Xfiat can also restrict live calls to your server addresses.
Requests and replies
Send JSON with Content-Type: application/json (32 KB at most). Amounts are strings with up to 8 decimals, e.g. "0.0015". Times are UTC, ISO 8601.
Every reply has the same shape:
{ "ok": true, "data": { ... }, "request_id": "req_8Fq2..." }
{ "ok": false, "error": { "code": "invalid_request", "message": "...", "field": "payout_amount" }, "request_id": "req_..." }Keep the request_id (also in the X-Request-Id header). Xfiat support can find any call with it.
Errors
| HTTP | code | What to do |
|---|---|---|
| 400 | invalid_request | Fix the field named in error.field. |
| 401 | unauthorized | Key missing or wrong, or a bad signature. |
| 403 | forbidden | This key may not do that (e.g. a public key on the API). |
| 403 | live_not_enabled | Your account is test only for now. |
| 403 | kyc_required | Xfiat asked for documents. Live payments resume once verified. |
| 403 | account_suspended | Contact Xfiat. |
| 404 | not_found | Wrong id, or an id from the other mode (test vs live). |
| 409 | conflict | Reference already used, Idempotency-Key reused with another body, or the payment moved on. |
| 413 / 415 | payload_too_large / unsupported_media_type | Send a JSON body under 32 KB. |
| 422 | limit_exceeded | Over a limit on your account (amount, daily volume, open payments). |
| 429 | rate_limited | Wait the seconds in Retry-After. |
| 503 | rate_unavailable, service_unavailable, endpoint_disabled | Retry later with the same Idempotency-Key. |
| 500 | server_error | Retry with the same Idempotency-Key. Tell Xfiat the request_id if it repeats. |
Limits
Every reply carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (Unix time). Over the limit you get 429 with Retry-After.
| Limit | Usual value |
|---|---|
| Calls per minute | 60 |
| Calls per day | 10,000 |
| New payments per minute | 10 |
| Per payment | $2 to $2,000 |
| Per 24 hours | $10,000 and 200 payments |
| Open payments at once | 100 |
Your own values are in GET /account. Xfiat raises them on request. Test and live are counted apart.
Retries (Idempotency-Key)
POST /payments and POST /checkouts need an Idempotency-Key header: any unique text of 8 to 80 characters, such as your order id. If the connection drops, send the same request with the same key: you get the first answer back, never a second payment. The same key with a different body returns 409. Keys are kept for 7 days.
reference is separate: it is your own id for the payment and must be unique per account and mode.
Signed requests (optional)
For extra safety Xfiat can require every call to be signed with your signing secret. Add two headers:
X-Xfiat-Timestamp: 1760450000 X-Xfiat-Signature: hex( HMAC-SHA256( signing_secret, timestamp + "." + METHOD + "." + path + "." + body ) )
path is the part after /v1, e.g. /payments. body is the exact JSON you send (empty for GET). The time must be within 5 minutes.
$ts = (string) time();
$sig = hash_hmac('sha256', $ts . '.POST./payments.' . $json, $signingSecret);
const ts = Math.floor(Date.now() / 1000).toString();
const sig = crypto.createHmac('sha256', signingSecret).update(`${ts}.POST./payments.${json}`).digest('hex');Endpoints
Check the service
No key needed. Returns {"service":"Xfiat API","version":"1.0.0","time":"…"}.
Your account
Status, whether live is allowed, open verification requests, your payout bank, your fee (fee_percent), your limits and the last 24 hours of use.
Coins, networks and the least amount
Every coin and network you can use, in one list, with min_amount: the least crypto worth paying after the network minimum, the bank fee and your account limit. memo_required means the payer must also send the memo (tag) shown on the payment.
| Filter | Example | |
|---|---|---|
coin | ?coin=LTC | One coin, with its least amount priced live. Use it before showing a converted coin. |
search | ?search=doge | Coins whose code or name contains the text. Good for a search box. |
payout_currency | ?payout_currency=NGN | Currency the least amounts are worked out for. |
{ "coins": [ { "coin": "USDT", "name": "Tether", "decimals": 2,
"networks": [ { "network": "tron", "name": "Tron (TRC20)", "min_amount": "1.20" },
{ "network": "ethereum", "name": "Ethereum (ERC20)", "min_amount": "5.00" } ] } ],
"payout_currencies": ["NGN"], "min_amount_for": "NGN" }Banks
Bank codes for payouts: [{"code":"058","name":"GTBank"}, …]. Cache it for a day.
Check a bank account
| Field | ||
|---|---|---|
bank_code | required | From /banks |
account_number | required | 10 digits for NGN |
currency | optional | Default NGN |
Returns account_name so your user can confirm it before paying. In test mode the name is TEST ACCOUNT.
Price a payment
Give coin, payout_currency and one of crypto_amount (what the payer sends) or payout_amount (what the bank gets). You get both amounts after every fee, plus min_crypto_amount. A quote is a guide for 60 seconds; the final amount is in the payment once paid.
Create a payment
| Field | ||
|---|---|---|
coin | required | Any coin code from /assets, e.g. USDT, BTC, LTC |
network | optional | From /assets. Default: tron for USDT, ethereum for USDC and ETH, bitcoin for BTC, binance for BNB. Required for a converted coin that has more than one network. |
payout_currency | optional | Default NGN |
payout_amount or crypto_amount | one | What the bank gets, or what the payer sends. |
bank | optional | {"bank_code":"058","account_number":"0123456789"}. Leave out (or "default") to use your registered bank. The name is checked with the bank. |
reference | optional | Your id, unique per account and mode. Letters, digits and . _ : / # -, up to 80. |
metadata | optional | Up to 20 keys of your own, returned as given. |
Header Idempotency-Key is required. Reply 201, a payment:
{
"id": "pay_3kQ9w2ZtY8bN1cV5xL0a", "object": "payment", "mode": "live", "status": "awaiting_payment",
"coin": "USDT", "network": "tron", "network_name": "Tron (TRC20)",
"deposit_address": "TQ7x...9fKp", "memo": null,
"crypto_amount": "33.67", "crypto_received": null,
"payout": { "currency": "NGN", "amount_estimate": "50000.00", "amount": null,
"bank": { "bank_code": "058", "bank_name": "GTBank", "account_name": "ADA OBI", "account_number_last4": "6789" } },
"reference": "ORDER-1001", "metadata": {}, "checkout_id": null,
"expires_at": "2026-10-14T15:42:10Z", "completed_at": null,
"created_at": "2026-10-14T14:42:10Z", "updated_at": "2026-10-14T14:42:10Z"
}crypto_amount of coin on network, before expires_at (1 hour), with the memo when there is one. Less is paid out on what arrived. Crypto sent on another network cannot be recovered.Converted coins work the same way. Their price moves until the coins arrive, so payout.amount_estimate is a guide and payout.amount is the exact amount once the bank is paid. A quote also returns max_crypto_amount for them.
List payments
Newest first. limit 1 to 100. Next page: starting_after= the last id. has_more says if there is one.
Get a payment
The latest state. Use webhooks rather than polling; if you must poll, once every 30 seconds is plenty.
Cancel a payment
Only while awaiting_payment. If crypto still arrives later it is paid out as normal and you get the webhooks.
Create a hosted page
Fields: payout_amount (required), payout_currency, coins (up to 10 codes; leave out to offer every coin: the main ones as buttons, the rest through a search box), reference (unique), description, success_url, cancel_url. Header Idempotency-Key required. Returns url to send the payer to, and payment_id once they start paying. Read it later with GET /checkouts/{id}. The page lasts 60 minutes and pays out to your registered bank.
Events
The events sent to your webhook, with delivery result. Handy to check what you missed.
Test your webhook
Sends a signed webhook.test event to your webhook now and tells you what your server answered.
Simulate (test mode)
Body {"status":"completed"}. Also confirming, processing, needs_review, expired, failed. Each step in between fires its own webhook, like a real payment.
Statuses
| Status | Meaning |
|---|---|
awaiting_payment | Address shown, nothing received yet. |
confirming | Crypto seen on the network, waiting for confirmations. |
processing | Received. Xfiat is paying the bank. Safe to tell your customer "paid". |
completed | Bank paid. payout.amount is the exact amount sent. |
needs_review | Received, but the bank payout needs a check by Xfiat. No action from you. Ends in completed. |
expired / cancelled | Nothing arrived in time, or you cancelled. A late payment still completes. |
failed | Stopped for good. Contact Xfiat with the payment id. |
Webhooks
Give Xfiat an https address on your server. On every change Xfiat sends a POST with the event and the full payment. Events: payment.created, payment.confirming, payment.processing, payment.completed, payment.needs_review, payment.expired, payment.cancelled, payment.failed.
{ "id": "evt_Wq1...", "object": "event", "type": "payment.completed", "mode": "live",
"created_at": "2026-10-14T14:51:03Z", "data": { ...payment... } }Check the signature
Header Xfiat-Signature: t=TIMESTAMP,v1=SIGNATURE. SIGNATURE is hex HMAC-SHA256 of TIMESTAMP + "." + raw body with your webhook secret (whsec_…). Use the raw body, before any JSON parsing.
$body = file_get_contents('php://input');
$head = $_SERVER['HTTP_XFIAT_SIGNATURE'] ?? '';
if (!preg_match('/t=(\d+),v1=([a-f0-9]{64})/', $head, $m)
|| abs(time() - (int) $m[1]) > 300
|| !hash_equals(hash_hmac('sha256', $m[1] . '.' . $body, getenv('XFIAT_WEBHOOK_SECRET')), $m[2])) {
http_response_code(400); exit;
}
$event = json_decode($body, true);
// ignore an event id you already handled, then update your order
http_response_code(200);
const crypto = require('crypto');
app.post('/xfiat-webhook', express.raw({ type: 'application/json' }), (req, res) => {
const m = /t=(\d+),v1=([a-f0-9]{64})/.exec(req.get('Xfiat-Signature') || '');
if (!m || Math.abs(Date.now() / 1000 - Number(m[1])) > 300) return res.sendStatus(400);
const want = crypto.createHmac('sha256', process.env.XFIAT_WEBHOOK_SECRET).update(m[1] + '.' + req.body).digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(want), Buffer.from(m[2]))) return res.sendStatus(400);
const event = JSON.parse(req.body);
// ignore an event id you already handled, then update your order
res.sendStatus(200);
});Reply 2xx within 8 seconds; do slow work afterwards. Webhooks only go to public https addresses and do not follow redirects.
Test mode
Test keys use the same endpoints and replies, with "mode": "test". No crypto is received and no bank is paid. Test addresses start with test_; do not send real crypto to them. Move a payment along with simulate. Test and live data never mix: a test id is not_found with a live key.
Going live
- Your webhook checks the signature and ignores repeated event ids.
- Every payment has a unique
referenceand every create call anIdempotency-Key. - Your secret key sits in server settings, not in code or a browser.
- You show the payer the exact amount, coin and network, and the time left.
- Ask Xfiat to allow live payments. You may be asked for business documents.
- Swap
sk_test_forsk_live_.
Questions: support@xfiat.app with your account id and a request_id. This page in plain text: api.txt.