#ForexPay OpenAPI
Version 1 · Last updated 2026-10-21
This is the integration guide for ForexPay merchants. Everything is plain JSON over HTTPS, authenticated with an API key and an HMAC-SHA256 signature. There is no SDK to install.
| Base URL (production) | https://openapi.forexpay.app |
| Base URL (sandbox) | supplied with your test credentials |
| Transport | HTTPS, POST, Content-Type: application/json |
| Authentication | X-FX-Key + HMAC-SHA256 signature |
| Timestamps | ISO 8601, always UTC (2026-10-21T07:04:05.000Z) |
| Amounts | JSON numbers, at most 2 decimal places |
#1. Credentials
When your account is enabled for the API you receive two values:
| Value | Example | Use |
|---|---|---|
| API key | fx_live_xxxxxxxxxxxxxxxxxxxxxxxx | sent in the X-FX-Key header |
| API secret | 43 characters | signs requests and verifies webhooks — never send it |
Keep the secret on your server. It is never transmitted, not even hashed. If it leaks, ask support to rotate it; the old secret stops working immediately.
#2. Signing a request
Every request carries four headers:
X-FX-Key: fx_live_xxxxxxxxxxxxxxxxxxxxxxxx
X-FX-Timestamp: 1792594663
X-FX-Nonce: 7f3a9c2e5b1d4a80
X-FX-Sign: <64 lowercase hex characters>
Content-Type: application/json
| Header | Rule |
|---|---|
X-FX-Timestamp | Unix time in seconds. Must be within ±5 minutes of our clock. |
X-FX-Nonce | 8–64 characters of A-Z a-z 0-9 _ -, random per request. |
X-FX-Sign | The signature below, lowercase hex. |
The signature is an HMAC-SHA256 over five lines joined with \n:
sign = HMAC-SHA256(secret,
METHOD + "\n" +
PATH + "\n" +
TIMESTAMP + "\n" +
NONCE + "\n" +
SHA256(BODY)
)
METHODis uppercase, alwaysPOST.PATHis the path only, e.g./v1/payin/create— no host, no query string.SHA256(BODY)is the lowercase hex digest of the exact bytes you send. Sign the serialized string, then send that same string — do not re-serialize in between. For an empty body, use the digest of the empty string.
#Worked example
secret = test-secret
method = POST
path = /v1/payin/create
timestamp = 1792594663
nonce = 7f3a9c2e
body = {"reference":"A1"}
SHA256(body) = 233abea419905ee5886f9637d90858d7f8fa6841eeb5f06ca8639f6f8480a6a4
signing string:
POST\n/v1/payin/create\n1792594663\n7f3a9c2e\n233abea4…a6a4
sign = 79b11f7a065d6bec2c5d303eb759c5b76c7b0cf2000b1bae908bd78f80b29a58
Use these values to check your implementation before you send a live request.
#Example code
Node.js
import crypto from 'node:crypto';
const BASE_URL = 'https://openapi.forexpay.app';
const API_KEY = process.env.FX_API_KEY;
const API_SECRET = process.env.FX_API_SECRET;
async function callForexPay(path, payload) {
const body = JSON.stringify(payload);
const timestamp = Math.floor(Date.now() / 1000);
const nonce = crypto.randomBytes(8).toString('hex');
const bodyHash = crypto.createHash('sha256').update(body).digest('hex');
const sign = crypto
.createHmac('sha256', API_SECRET)
.update(['POST', path, timestamp, nonce, bodyHash].join('\n'))
.digest('hex');
const response = await fetch(BASE_URL + path, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-fx-key': API_KEY,
'x-fx-timestamp': String(timestamp),
'x-fx-nonce': nonce,
'x-fx-sign': sign,
},
body, // the same string that was hashed
});
const result = await response.json();
if (!result.ok) {
throw new Error(`${result.error.code}: ${result.error.message} (${result.request_id})`);
}
return result.data;
}
const order = await callForexPay('/v1/payin/create', {
reference: 'ORDER-20261021-0001',
amount: 100.5,
currency: 'CNY',
customer: { name: 'Zhang San' },
});
console.log(order.checkout_url);
PHP
<?php
function call_forexpay(string $path, array $payload): array {
$base = 'https://openapi.forexpay.app';
$key = getenv('FX_API_KEY');
$secret = getenv('FX_API_SECRET');
$body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$timestamp = (string) time();
$nonce = bin2hex(random_bytes(8));
$signing = implode("\n", ['POST', $path, $timestamp, $nonce, hash('sha256', $body)]);
$sign = hash_hmac('sha256', $signing, $secret);
$ch = curl_init($base . $path);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-FX-Key: ' . $key,
'X-FX-Timestamp: ' . $timestamp,
'X-FX-Nonce: ' . $nonce,
'X-FX-Sign: ' . $sign,
],
]);
$raw = curl_exec($ch);
curl_close($ch);
$result = json_decode($raw, true);
if (empty($result['ok'])) {
throw new RuntimeException($result['error']['code'] . ': ' . $result['error']['message']);
}
return $result['data'];
}
Python
import hashlib, hmac, json, os, secrets, time
import requests
BASE_URL = "https://openapi.forexpay.app"
def call_forexpay(path: str, payload: dict) -> dict:
body = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
timestamp = str(int(time.time()))
nonce = secrets.token_hex(8)
signing = "\n".join(["POST", path, timestamp, nonce,
hashlib.sha256(body.encode()).hexdigest()])
sign = hmac.new(os.environ["FX_API_SECRET"].encode(), signing.encode(),
hashlib.sha256).hexdigest()
response = requests.post(
BASE_URL + path,
data=body.encode(),
headers={
"Content-Type": "application/json",
"X-FX-Key": os.environ["FX_API_KEY"],
"X-FX-Timestamp": timestamp,
"X-FX-Nonce": nonce,
"X-FX-Sign": sign,
},
timeout=35,
)
result = response.json()
if not result.get("ok"):
raise RuntimeError(f"{result['error']['code']}: {result['error']['message']}")
return result["data"]
#3. Response format
Success — HTTP 200:
{
"ok": true,
"request_id": "req_8f2a1c7d4e5b6a9c0d1e2f3a",
"data": { }
}
Failure — HTTP 4xx or 5xx:
{
"ok": false,
"request_id": "req_8f2a1c7d4e5b6a9c0d1e2f3a",
"error": { "code": "INVALID_AMOUNT", "message": "amount: at most 2 decimal places" }
}
Always branch on ok, not on the HTTP status. Log request_id on every call —
it is the only thing support needs to find your request.
#4. Endpoints
| Endpoint | Purpose |
|---|---|
POST /v1/payin/create | Collect money, hosted checkout page |
POST /v1/payin/create-raw | Collect money, you build the checkout page |
POST /v1/payin/query | Look up a collection order |
POST /v1/payin/cancel | Cancel an unpaid collection order |
POST /v1/payin/confirm | Mark a collection order as paid (self-hosted checkout only) |
POST /v1/payin/methods | Currencies and channels available to you |
POST /v1/payout/create | Send money |
POST /v1/payout/query | Look up a payout order |
POST /v1/account/balance | Balance and payout rates |
POST /v1/account/statements | Ledger entries |
POST /v1/rates/payin | Collection rate |
POST /v1/rates/payout | Payout rate |
reference is your order number. It must be unique across both collection and
payout orders on your account — reusing one is rejected with DUPLICATE_REFERENCE,
and the query endpoints look orders up by it.
Allowed characters: A-Z a-z 0-9 _ . -, 1–64 characters.
#4.1 POST /v1/payin/create
Creates a collection order and returns a hosted checkout URL to send your customer to.
Request
| Field | Type | Required | Notes |
|---|---|---|---|
reference | string | yes | your order number, see above |
amount | number | yes | > 0, at most 2 decimals |
currency | string | yes | 3-letter code, e.g. CNY |
channel | integer | no | payment channel; omit to let us choose. See /v1/payin/methods |
customer.name | string | no | payer name, ≤ 50 chars |
customer.id_no | string | no | payer ID number, ≤ 64 chars |
customer.phone | string | no | ≤ 32 chars |
customer.country | string | no | ≤ 50 chars |
return_url | string | no | where the checkout page sends the customer afterwards |
notify_url | string | no | per-order webhook; see §5 and §7 before using it |
hide_crypto | boolean | no | hide crypto payment options on the checkout page |
Response
| Field | Type | Notes |
|---|---|---|
reference | string | echo of your order number |
checkout_url | string | send the customer here |
{
"ok": true,
"request_id": "req_…",
"data": {
"reference": "ORDER-20261021-0001",
"checkout_url": "https://pay.forexpay.app/#/step1?orderId=…&checkoutToken=…"
}
}
#4.2 POST /v1/payin/create-raw
Same request as /v1/payin/create, but instead of a checkout URL you get the payment
accounts to render yourself. You are then responsible for calling /v1/payin/confirm
when the customer tells you they have paid.
Response
| Field | Type | Notes |
|---|---|---|
reference | string | echo |
order_no | string | our order number, quote it in support requests |
amount | number | |
currency | string | |
settled_usdt | number | what the order settles to in your balance |
created_at | string | ISO 8601 UTC |
payment_accounts[] | array | one or more accounts the customer may pay into |
payment_accounts[].account_id | integer | pass this back to /v1/payin/confirm |
payment_accounts[].channel | integer | channel code |
payment_accounts[].holder_name | string | account holder |
payment_accounts[].card_no | string|null | bank account number |
payment_accounts[].wallet_no | string|null | e-wallet account or phone number |
payment_accounts[].bank_name | string|null | |
payment_accounts[].bank_branch | string|null | |
payment_accounts[].qr_code | string|null | payment QR payload or image URL |
#4.3 POST /v1/payin/query
Request: reference (string, required).
Response
| Field | Type | Notes |
|---|---|---|
reference | string | echo |
status | string | see §6 |
is_final | boolean | true means the status will not change again |
amount | number | |
currency | string | |
rate | number | |
fee | number | |
channel | integer|null | channel code, when we can resolve one |
channel_label | string|null | channel as we recorded it |
paid_at | string|null | when the customer paid |
settled_at | string|null | when it was credited to your balance |
#4.4 POST /v1/payin/cancel
Cancels a collection order that is still pending. Anything else returns
ORDER_STATE_INVALID.
Request: reference (string, required).
Response: reference, status ("cancelled").
#4.5 POST /v1/payin/confirm
Only for self-hosted checkout (/v1/payin/create-raw). Tells us the customer says they
paid into a specific account; the order moves to verifying until we confirm the money
arrived.
Request
| Field | Type | Required | Notes |
|---|---|---|---|
reference | string | yes | |
account_id | integer | yes | must be one of the account_ids returned for this order, otherwise ACCOUNT_NOT_IN_ORDER |
Response: reference, status ("verifying").
#4.6 POST /v1/payin/methods
Request: currency (string, optional — omit for every currency enabled on your account).
Response
| Field | Type | Notes |
|---|---|---|
currencies[].currency | string | |
currencies[].symbol | string|null | |
currencies[].decimals | integer | minor units for this currency |
currencies[].channels[].channel | integer | the value to pass as channel |
currencies[].channels[].name | string | display name |
currencies[].channels[].kind | string | e.g. bank, wallet |
currencies[].channels[].fields[].param | string|null | the payout field to fill, e.g. payee.bank_code. null means it cannot be supplied over the API |
currencies[].channels[].fields[].label | string | |
currencies[].channels[].fields[].required | boolean | |
currencies[].channels[].fields[].pattern | string|null | regular expression the value must match |
currencies[].channels[].fields[].hint | string|null | |
currencies[].channels[].banks[] | array | { name, code }, when the channel needs a bank code |
Call this before building a payout form: it tells you exactly which payee.* fields a
channel requires.
#4.7 POST /v1/payout/create
Request
| Field | Type | Required | Notes |
|---|---|---|---|
reference | string | yes | |
amount | number | yes | > 0, at most 2 decimals |
currency | string | yes | |
channel | integer | no | from /v1/payin/methods; omit for a local bank transfer |
expedited | boolean | no | priority processing, different rate |
payee.name | string | yes | beneficiary name, ≤ 100 |
payee.account_no | string | yes | account number, card number, wallet ID or phone, ≤ 100 |
payee.bank_name | string | no | ≤ 100 |
payee.bank_branch | string | no | ≤ 100 |
payee.bank_code | string | no | routing/sort/bank code, ≤ 50 |
payee.swift | string | no | SWIFT/BIC, ≤ 50 |
payee.iban | string | no | ≤ 50 |
payee.bank_address | string | no | ≤ 200 |
payee.address | string | no | beneficiary address, ≤ 200 |
payee.id_no | string | no | beneficiary document (e.g. CPF/CNPJ), ≤ 50 |
notify_url | string | no | see §7 — not available for international wires |
Which payee.* fields are required depends on the channel; PAYEE_INFO_INVALID tells you
the set was wrong. The amount plus fees is held from your balance when the order is
accepted; INSUFFICIENT_BALANCE means top up first.
Response: reference, status ("pending").
The order number is not available at creation time. Poll
/v1/payout/querywith yourreference, or wait for the webhook.
#4.8 POST /v1/payout/query
Request: reference (string, required).
Response: same shape as /v1/payin/query minus settled_at, with payout statuses
(§6). Collection and payout statuses are different sets — use the endpoint that
matches the order you created.
#4.9 POST /v1/account/balance
Request: no fields (send {}).
Response
| Field | Type | Notes |
|---|---|---|
balance_usdt | number | available balance |
frozen_usdt | number | held by in-flight payouts |
cny_payout_rate | number | legacy CNY payout rate |
payout_rates[].currency | string | |
payout_rates[].rate | number | standard payout rate |
payout_rates[].expedited_rate | number | rate with expedited: true |
#4.10 POST /v1/account/statements
Request
| Field | Type | Required | Notes |
|---|---|---|---|
from | string | yes | ISO 8601, inclusive |
to | string | yes | ISO 8601, exclusive. The range must be ≤ 24 hours |
type_code | integer | no | filter to one entry type |
order_no | string | no | filter to one order |
Response
| Field | Type | Notes |
|---|---|---|
total | integer | number of entries returned |
items[].id | integer | |
items[].type | string | stable code, e.g. buy, withdrawal, fee |
items[].type_code | integer | numeric form, pass back as type_code |
items[].type_label | string | human-readable label |
items[].amount | number | signed; negative is a debit |
items[].currency | string | |
items[].balance | number | balance after this entry |
items[].frozen | number | held amount after this entry |
items[].fee | number | |
items[].order_no | string|null | related order |
items[].remark | string|null | |
items[].force_released | boolean | |
items[].posted_at | string | ISO 8601 UTC |
There is no pagination: narrow the window instead.
#4.11 POST /v1/rates/payin
Request: currency (string, required).
Response: currency, rate (number).
A rate is only quoted when a channel can actually take an order; otherwise
RATE_UNAVAILABLE. Rates are indicative — the rate on the order is fixed when the order
is created.
#4.12 POST /v1/rates/payout
Request: currency (string, required), expedited (boolean, optional).
Response: currency, expedited, rate.
#5. Webhooks
We POST a JSON event to your webhook URL when an order reaches a final state.
POST https://your-server.example.com/webhooks/forexpay
X-FX-Event: payin.succeeded
X-FX-Timestamp: 1792594663
X-FX-Sign: <64 lowercase hex characters>
Content-Type: application/json
{
"event": "payin.succeeded",
"sent_at": "2026-10-21T07:05:00.000Z",
"data": {
"reference": "ORDER-20261021-0001",
"order_no": "PI1234567890123456",
"status": "success",
"amount": 100.5,
"settled_usdt": 13.9,
"paid_at": "2026-10-21T07:04:05.000Z"
}
}
| Event | When |
|---|---|
payin.succeeded | a collection order was paid and credited |
payout.succeeded | a payout was sent |
payout.failed | a payout was rejected; the held amount is returned to your balance |
#Verifying the signature
The webhook signature is not the same formula as the request signature — it has no method or path, so a reverse proxy rewriting your path cannot break it:
X-FX-Sign = HMAC-SHA256(secret, X-FX-Timestamp + "\n" + SHA256(raw_body))
import crypto from 'node:crypto';
function verifyWebhook(rawBody, headers, secret) {
const timestamp = headers['x-fx-timestamp'];
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}\n${crypto.createHash('sha256').update(rawBody).digest('hex')}`)
.digest('hex');
const received = headers['x-fx-sign'] ?? '';
return (
expected.length === received.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received)) &&
Math.abs(Date.now() / 1000 - Number(timestamp)) < 300
);
}
Verify against the raw request body, before any JSON parsing or re-serialization.
#Responding
Reply HTTP 200 with the body ok. Anything else — another status, another body, a
timeout — counts as a failure and we retry.
#Retries
A failed delivery is retried up to 5 times with a growing delay (roughly 3, 9, 27, 81 and
243 minutes). Treat webhooks as at-least-once: the same event can arrive twice, so key
your handler on reference and make it idempotent. Always confirm against
/v1/payin/query or /v1/payout/query before releasing goods — the query endpoints are
the source of truth.
Our requests time out after 10 seconds. Acknowledge first, process afterwards.
#Where events are sent
By default events go to the webhook URL registered on your account — one for collections and, optionally, a separate one for payouts. Ask support to change them.
You may also override it per order with notify_url, subject to the size limit in §7.
#6. Statuses
Collection orders (/v1/payin/query, payin.* events)
| Status | Meaning | Final |
|---|---|---|
pending | waiting for the customer to pay | no |
verifying | customer says they paid, we are confirming | no |
success | paid and credited to your balance | yes |
cancelled | cancelled before payment | yes |
expired | the customer did not pay in time | yes |
reversed | a credited order was reversed afterwards | yes |
Payout orders (/v1/payout/query, payout.* events)
| Status | Meaning | Final |
|---|---|---|
pending | accepted, not yet assigned | no |
processing | being paid out | no |
success | sent | yes |
failed | rejected; the held amount is back in your balance | yes |
is_final in the query responses is a convenience for exactly these columns.
Treat any status you do not recognise as non-final and keep polling.
#7. Field lengths and the request size budget
Each request is re-encrypted before it reaches our core, and that encryption takes a fixed maximum of 501 bytes of JSON. All the per-field limits in §4 fit comfortably inside it, but the total matters. Real measurements:
| Request | Bytes |
|---|---|
| Collection, required fields only | 104 |
| Collection, every optional field | 340 |
Collection, every optional field + notify_url | 486 |
| Payout, local bank transfer | 252 |
| Payout, international wire (SWIFT + addresses) | 363 |
Payout, international wire + notify_url | 509 ✗ |
A per-order notify_url costs about 129 bytes of that budget. So:
notify_urlworks for normal collection and payout orders.notify_urldoes not fit an international wire payout with bank and beneficiary addresses. Use the webhook URL registered on your account for those.
If a request does not fit you get a clear error before anything is created:
{
"ok": false,
"request_id": "req_…",
"error": {
"code": "NOTIFY_URL_TOO_LONG",
"message": "The request with notify_url needs 509 bytes but only 501 fit. Drop notify_url (the webhook registered on your account is used instead) or shorten the optional fields."
}
}
REQUEST_TOO_LARGE is the same situation without notify_url: shorten the optional text
fields (return_url, addresses, remarks).
#8. Errors
error.code is a stable string — branch on it. error.message is for humans and may be
reworded at any time.
| Code | HTTP | Meaning | What to do |
|---|---|---|---|
INVALID_REQUEST | 400 | malformed body or an invalid field | the message names the field |
MISSING_PARAMETER | 400 | a required field is missing | the message names the field |
MISSING_HEADER | 401 | an auth header is missing | see §2 |
INVALID_API_KEY | 401 | unknown or revoked key | check X-FX-Key |
INVALID_SIGNATURE | 401 | signature mismatch | re-check §2, especially that you hash the exact bytes you send |
TIMESTAMP_EXPIRED | 401 | clock more than 5 minutes off | sync your clock (NTP) |
DUPLICATE_NONCE | 409 | nonce reused | use a fresh random nonce per request |
NOT_FOUND | 404 | no such endpoint | check the path |
METHOD_NOT_ALLOWED | 405 | not a POST | |
PAYLOAD_TOO_LARGE | 413 | body too big | |
REQUEST_TOO_LARGE | 400 | too much field data to forward | see §7 |
NOTIFY_URL_TOO_LONG | 400 | notify_url does not fit | see §7 |
MERCHANT_NOT_FOUND | 403 | account not provisioned for the API | contact support |
MERCHANT_SUSPENDED | 403 | account suspended | contact support |
API_DISABLED | 403 | API access disabled | contact support |
IP_NOT_ALLOWED | 403 | caller IP not on your allow-list | ask support to update it |
NOTIFY_URL_MISSING | 400 | no webhook registered and none supplied | register one or send notify_url |
DUPLICATE_REFERENCE | 409 | reference already used | use a new one; query the old order instead |
CURRENCY_UNSUPPORTED | 400 | currency not supported | see /v1/payin/methods |
CURRENCY_NOT_ENABLED | 403 | currency not enabled for you | contact support |
CHANNEL_UNSUPPORTED | 400 | channel not valid for that currency | see /v1/payin/methods |
NO_CHANNEL_AVAILABLE | 503 | no channel can take this order now | retry in a minute or two |
RATE_UNAVAILABLE | 503 | no rate for that currency now | retry shortly |
AMOUNT_OUT_OF_RANGE | 400 | amount outside the channel's range | try a different amount or channel |
INVALID_AMOUNT | 400 | ≤ 0 or more than 2 decimals | |
CUSTOMER_BLOCKED | 403 | this customer cannot place orders | contact support |
PAYEE_INFO_INVALID | 400 | payee.* does not match the channel | see /v1/payin/methods |
INSUFFICIENT_BALANCE | 402 | not enough balance for the payout | top up |
ORDER_NOT_FOUND | 404 | no order with that reference | |
ORDER_STATE_INVALID | 409 | the order's state does not allow this | query it first |
ACCOUNT_NOT_IN_ORDER | 400 | account_id is not from this order | use an account_id from create-raw |
REQUEST_FAILED | 400 | rejected, no more specific code | contact support with request_id |
UPSTREAM_ERROR | 502 | processing error on our side | safe to retry |
UPSTREAM_TIMEOUT | 504 | we timed out internally | do not assume failure — query by reference |
INTERNAL_ERROR | 500 | unexpected error | contact support with request_id |
#Retrying safely
Reusing the same reference is always safe: a duplicate gets DUPLICATE_REFERENCE
rather than a second order. So after a timeout or a 5xx, either retry with the same
reference or query it — never create a new one.
#9. Going live checklist
- Reproduce the §2 worked example exactly.
- Sandbox: create a collection order, pay it, receive and verify the webhook.
- Your webhook replies
okand is idempotent (send it the same event twice). - Your server clock is NTP-synced.
- You store
reference,order_noandrequest_idfor every call. - You poll
/v1/payin/queryas a backstop instead of trusting webhooks alone. - You never create a second order for a failed or timed-out request.
- Secrets are in environment variables or a secret manager, not in source control.