Xfiat APIv1Plain textxfiat.app

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.

Your server creates a payment, the payer sends crypto, Xfiat pays the bank and tells your server
One payment, start to finish.

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

  1. Ask Xfiat for an account. You get test keys straight away.
  2. Make a test payment (below). Nothing real moves in test mode.
  3. Move it to completed with simulate and watch your webhook receive the events.
  4. 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"}'

Show the payer deposit_address, crypto_amount, coin and network_name. Then wait for the webhook.

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.

Payment page with amount, crypto to send, QR code, address and status
What the payer sees.

Keys

KeyUse
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

A request: method and endpoint, Authorization header, Idempotency-Key, JSON body, reference
The parts of a request.

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

HTTPcodeWhat to do
400invalid_requestFix the field named in error.field.
401unauthorizedKey missing or wrong, or a bad signature.
403forbiddenThis key may not do that (e.g. a public key on the API).
403live_not_enabledYour account is test only for now.
403kyc_requiredXfiat asked for documents. Live payments resume once verified.
403account_suspendedContact Xfiat.
404not_foundWrong id, or an id from the other mode (test vs live).
409conflictReference already used, Idempotency-Key reused with another body, or the payment moved on.
413 / 415payload_too_large / unsupported_media_typeSend a JSON body under 32 KB.
422limit_exceededOver a limit on your account (amount, daily volume, open payments).
429rate_limitedWait the seconds in Retry-After.
503rate_unavailable, service_unavailable, endpoint_disabledRetry later with the same Idempotency-Key.
500server_errorRetry 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.

LimitUsual value
Calls per minute60
Calls per day10,000
New payments per minute10
Per payment$2 to $2,000
Per 24 hours$10,000 and 200 payments
Open payments at once100

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);

Endpoints

Check the service

GET/ping

No key needed. Returns {"service":"Xfiat API","version":"1.0.0","time":"…"}.

Your account

GET/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

GET/assets?payout_currency=NGN

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.

FilterExample
coin?coin=LTCOne coin, with its least amount priced live. Use it before showing a converted coin.
search?search=dogeCoins whose code or name contains the text. Good for a search box.
payout_currency?payout_currency=NGNCurrency 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

GET/banks?currency=NGN

Bank codes for payouts: [{"code":"058","name":"GTBank"}, …]. Cache it for a day.

Check a bank account

POST/banks/resolve
Field
bank_coderequiredFrom /banks
account_numberrequired10 digits for NGN
currencyoptionalDefault NGN

Returns account_name so your user can confirm it before paying. In test mode the name is TEST ACCOUNT.

Price a payment

POST/quotes

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

POST/payments
Field
coinrequiredAny coin code from /assets, e.g. USDT, BTC, LTC
networkoptionalFrom /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_currencyoptionalDefault NGN
payout_amount or crypto_amountoneWhat the bank gets, or what the payer sends.
bankoptional{"bank_code":"058","account_number":"0123456789"}. Leave out (or "default") to use your registered bank. The name is checked with the bank.
referenceoptionalYour id, unique per account and mode. Letters, digits and . _ : / # -, up to 80.
metadataoptionalUp 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"
}
The payer must send exactly 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

GET/payments?limit=20&status=completed&reference=ORDER-1001

Newest first. limit 1 to 100. Next page: starting_after= the last id. has_more says if there is one.

Get a payment

GET/payments/{id}

The latest state. Use webhooks rather than polling; if you must poll, once every 30 seconds is plenty.

Cancel a payment

POST/payments/{id}/cancel

Only while awaiting_payment. If crypto still arrives later it is paid out as normal and you get the webhooks.

Create a hosted page

POST/checkouts

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

GET/events?limit=20

The events sent to your webhook, with delivery result. Handy to check what you missed.

Test your webhook

POST/webhooks/test

Sends a signed webhook.test event to your webhook now and tells you what your server answered.

Simulate (test mode)

POST/test/payments/{id}/simulate

Body {"status":"completed"}. Also confirming, processing, needs_review, expired, failed. Each step in between fires its own webhook, like a real payment.

Statuses

awaiting_payment, confirming, processing, completed; also expired, cancelled, needs_review, failed
How a payment moves.
StatusMeaning
awaiting_paymentAddress shown, nothing received yet.
confirmingCrypto seen on the network, waiting for confirmations.
processingReceived. Xfiat is paying the bank. Safe to tell your customer "paid".
completedBank paid. payout.amount is the exact amount sent.
needs_reviewReceived, but the bank payout needs a check by Xfiat. No action from you. Ends in completed.
expired / cancelledNothing arrived in time, or you cancelled. A late payment still completes.
failedStopped 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.

Xfiat posts the event with a signature; your server checks it and replies 200
Delivery and retries.
{ "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);

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

  1. Your webhook checks the signature and ignores repeated event ids.
  2. Every payment has a unique reference and every create call an Idempotency-Key.
  3. Your secret key sits in server settings, not in code or a browser.
  4. You show the payer the exact amount, coin and network, and the time left.
  5. Ask Xfiat to allow live payments. You may be asked for business documents.
  6. Swap sk_test_ for sk_live_.

Questions: support@xfiat.app with your account id and a request_id. This page in plain text: api.txt.