/OpenAPI

#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
TransportHTTPS, POST, Content-Type: application/json
AuthenticationX-FX-Key + HMAC-SHA256 signature
TimestampsISO 8601, always UTC (2026-10-21T07:04:05.000Z)
AmountsJSON numbers, at most 2 decimal places

#1. Credentials

When your account is enabled for the API you receive two values:

ValueExampleUse
API keyfx_live_xxxxxxxxxxxxxxxxxxxxxxxxsent in the X-FX-Key header
API secret43 characterssigns 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
HeaderRule
X-FX-TimestampUnix time in seconds. Must be within ±5 minutes of our clock.
X-FX-Nonce8–64 characters of A-Z a-z 0-9 _ -, random per request.
X-FX-SignThe 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)
)

#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

EndpointPurpose
POST /v1/payin/createCollect money, hosted checkout page
POST /v1/payin/create-rawCollect money, you build the checkout page
POST /v1/payin/queryLook up a collection order
POST /v1/payin/cancelCancel an unpaid collection order
POST /v1/payin/confirmMark a collection order as paid (self-hosted checkout only)
POST /v1/payin/methodsCurrencies and channels available to you
POST /v1/payout/createSend money
POST /v1/payout/queryLook up a payout order
POST /v1/account/balanceBalance and payout rates
POST /v1/account/statementsLedger entries
POST /v1/rates/payinCollection rate
POST /v1/rates/payoutPayout 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

FieldTypeRequiredNotes
referencestringyesyour order number, see above
amountnumberyes> 0, at most 2 decimals
currencystringyes3-letter code, e.g. CNY
channelintegernopayment channel; omit to let us choose. See /v1/payin/methods
customer.namestringnopayer name, ≤ 50 chars
customer.id_nostringnopayer ID number, ≤ 64 chars
customer.phonestringno≤ 32 chars
customer.countrystringno≤ 50 chars
return_urlstringnowhere the checkout page sends the customer afterwards
notify_urlstringnoper-order webhook; see §5 and §7 before using it
hide_cryptobooleannohide crypto payment options on the checkout page

Response

FieldTypeNotes
referencestringecho of your order number
checkout_urlstringsend 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

FieldTypeNotes
referencestringecho
order_nostringour order number, quote it in support requests
amountnumber
currencystring
settled_usdtnumberwhat the order settles to in your balance
created_atstringISO 8601 UTC
payment_accounts[]arrayone or more accounts the customer may pay into
payment_accounts[].account_idintegerpass this back to /v1/payin/confirm
payment_accounts[].channelintegerchannel code
payment_accounts[].holder_namestringaccount holder
payment_accounts[].card_nostring|nullbank account number
payment_accounts[].wallet_nostring|nulle-wallet account or phone number
payment_accounts[].bank_namestring|null
payment_accounts[].bank_branchstring|null
payment_accounts[].qr_codestring|nullpayment QR payload or image URL

#4.3 POST /v1/payin/query

Request: reference (string, required).

Response

FieldTypeNotes
referencestringecho
statusstringsee §6
is_finalbooleantrue means the status will not change again
amountnumber
currencystring
ratenumber
feenumber
channelinteger|nullchannel code, when we can resolve one
channel_labelstring|nullchannel as we recorded it
paid_atstring|nullwhen the customer paid
settled_atstring|nullwhen 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

FieldTypeRequiredNotes
referencestringyes
account_idintegeryesmust 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

FieldTypeNotes
currencies[].currencystring
currencies[].symbolstring|null
currencies[].decimalsintegerminor units for this currency
currencies[].channels[].channelintegerthe value to pass as channel
currencies[].channels[].namestringdisplay name
currencies[].channels[].kindstringe.g. bank, wallet
currencies[].channels[].fields[].paramstring|nullthe payout field to fill, e.g. payee.bank_code. null means it cannot be supplied over the API
currencies[].channels[].fields[].labelstring
currencies[].channels[].fields[].requiredboolean
currencies[].channels[].fields[].patternstring|nullregular expression the value must match
currencies[].channels[].fields[].hintstring|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

FieldTypeRequiredNotes
referencestringyes
amountnumberyes> 0, at most 2 decimals
currencystringyes
channelintegernofrom /v1/payin/methods; omit for a local bank transfer
expeditedbooleannopriority processing, different rate
payee.namestringyesbeneficiary name, ≤ 100
payee.account_nostringyesaccount number, card number, wallet ID or phone, ≤ 100
payee.bank_namestringno≤ 100
payee.bank_branchstringno≤ 100
payee.bank_codestringnorouting/sort/bank code, ≤ 50
payee.swiftstringnoSWIFT/BIC, ≤ 50
payee.ibanstringno≤ 50
payee.bank_addressstringno≤ 200
payee.addressstringnobeneficiary address, ≤ 200
payee.id_nostringnobeneficiary document (e.g. CPF/CNPJ), ≤ 50
notify_urlstringnosee §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/query with your reference, 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

FieldTypeNotes
balance_usdtnumberavailable balance
frozen_usdtnumberheld by in-flight payouts
cny_payout_ratenumberlegacy CNY payout rate
payout_rates[].currencystring
payout_rates[].ratenumberstandard payout rate
payout_rates[].expedited_ratenumberrate with expedited: true

#4.10 POST /v1/account/statements

Request

FieldTypeRequiredNotes
fromstringyesISO 8601, inclusive
tostringyesISO 8601, exclusive. The range must be ≤ 24 hours
type_codeintegernofilter to one entry type
order_nostringnofilter to one order

Response

FieldTypeNotes
totalintegernumber of entries returned
items[].idinteger
items[].typestringstable code, e.g. buy, withdrawal, fee
items[].type_codeintegernumeric form, pass back as type_code
items[].type_labelstringhuman-readable label
items[].amountnumbersigned; negative is a debit
items[].currencystring
items[].balancenumberbalance after this entry
items[].frozennumberheld amount after this entry
items[].feenumber
items[].order_nostring|nullrelated order
items[].remarkstring|null
items[].force_releasedboolean
items[].posted_atstringISO 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"
  }
}
EventWhen
payin.succeededa collection order was paid and credited
payout.succeededa payout was sent
payout.faileda 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)

StatusMeaningFinal
pendingwaiting for the customer to payno
verifyingcustomer says they paid, we are confirmingno
successpaid and credited to your balanceyes
cancelledcancelled before paymentyes
expiredthe customer did not pay in timeyes
reverseda credited order was reversed afterwardsyes

Payout orders (/v1/payout/query, payout.* events)

StatusMeaningFinal
pendingaccepted, not yet assignedno
processingbeing paid outno
successsentyes
failedrejected; the held amount is back in your balanceyes

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:

RequestBytes
Collection, required fields only104
Collection, every optional field340
Collection, every optional field + notify_url486
Payout, local bank transfer252
Payout, international wire (SWIFT + addresses)363
Payout, international wire + notify_url509 ✗

A per-order notify_url costs about 129 bytes of that budget. So:

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.

CodeHTTPMeaningWhat to do
INVALID_REQUEST400malformed body or an invalid fieldthe message names the field
MISSING_PARAMETER400a required field is missingthe message names the field
MISSING_HEADER401an auth header is missingsee §2
INVALID_API_KEY401unknown or revoked keycheck X-FX-Key
INVALID_SIGNATURE401signature mismatchre-check §2, especially that you hash the exact bytes you send
TIMESTAMP_EXPIRED401clock more than 5 minutes offsync your clock (NTP)
DUPLICATE_NONCE409nonce reuseduse a fresh random nonce per request
NOT_FOUND404no such endpointcheck the path
METHOD_NOT_ALLOWED405not a POST
PAYLOAD_TOO_LARGE413body too big
REQUEST_TOO_LARGE400too much field data to forwardsee §7
NOTIFY_URL_TOO_LONG400notify_url does not fitsee §7
MERCHANT_NOT_FOUND403account not provisioned for the APIcontact support
MERCHANT_SUSPENDED403account suspendedcontact support
API_DISABLED403API access disabledcontact support
IP_NOT_ALLOWED403caller IP not on your allow-listask support to update it
NOTIFY_URL_MISSING400no webhook registered and none suppliedregister one or send notify_url
DUPLICATE_REFERENCE409reference already useduse a new one; query the old order instead
CURRENCY_UNSUPPORTED400currency not supportedsee /v1/payin/methods
CURRENCY_NOT_ENABLED403currency not enabled for youcontact support
CHANNEL_UNSUPPORTED400channel not valid for that currencysee /v1/payin/methods
NO_CHANNEL_AVAILABLE503no channel can take this order nowretry in a minute or two
RATE_UNAVAILABLE503no rate for that currency nowretry shortly
AMOUNT_OUT_OF_RANGE400amount outside the channel's rangetry a different amount or channel
INVALID_AMOUNT400≤ 0 or more than 2 decimals
CUSTOMER_BLOCKED403this customer cannot place orderscontact support
PAYEE_INFO_INVALID400payee.* does not match the channelsee /v1/payin/methods
INSUFFICIENT_BALANCE402not enough balance for the payouttop up
ORDER_NOT_FOUND404no order with that reference
ORDER_STATE_INVALID409the order's state does not allow thisquery it first
ACCOUNT_NOT_IN_ORDER400account_id is not from this orderuse an account_id from create-raw
REQUEST_FAILED400rejected, no more specific codecontact support with request_id
UPSTREAM_ERROR502processing error on our sidesafe to retry
UPSTREAM_TIMEOUT504we timed out internallydo not assume failure — query by reference
INTERNAL_ERROR500unexpected errorcontact 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