MekaPay Developers
Developer documentation

Payment Gateway API

Integration guide for USDT deposits on BNB Smart Chain (BEP20) and TRON (TRC20): signed requests, per-user deposit addresses, the hosted payment page and callbacks, plus balances, settlements and payouts.

Version
v1.0
Published
12 November 2026
API base URL
https://api.mekapay.online/api/gateway/v1
Online docs
https://doc.mekapay.online
FIXED AMOUNT · ANY AMOUNT · SIGNED CALLBACKSdoc.mekapay.online

MekaPay Payment Gateway

Accept USDT from your users on BNB Smart Chain (BEP20) and TRON (TRC20). Each of your users gets their own deposit address, MekaPay hosts the payment page, watches the chain and calls your server back when the payment is confirmed.

How it works

  1. Your server→MekaPay API

    Create a payment order

    Your backend calls POST /orders, signed with your API Secret, with your own order id, the paying user's id, the payment mode, the chain and (for fixed-amount orders) the amount.

  2. MekaPay API

    Authenticate and assign an address

    MekaPay verifies the signature and that the call comes from one of your whitelisted server IPs, creates an order with a unique orderId and reference, and gives it a deposit address: a single-use address from your address pool, valid for this order only (or the user's permanent address, if they already have one).

  3. MekaPay API→Your server

    Return the address and a payment link

    The response carries the deposit address, the amount, the expiry time, the reference and a paymentUrl such as https://mekapay.online/pay/po_…. Send the user there, or show the details in your own interface.

  4. User→Blockchain

    The user pays before the time limit

    The user sends USDT on the right network to the address, by default within 20 minutes (you can choose 15–60). The payment page counts down and shows confirmation progress live.

  5. MekaPay→Your callback URL

    MekaPay confirms and calls you back

    When the transfer is final on-chain, MekaPay sends a signed payment.paid callback to your callback URL. Your system verifies it, credits the user once, and carries on with its own flow.

At a glance

API base URL
https://api.mekapay.online/api/gateway/v1
Authentication
HMAC-SHA256 signature on every request: X-MP-Client-Key, X-MP-Timestamp, X-MP-Signature
IP whitelist
Required. API requests are accepted only from the server IP addresses MekaPay support has whitelisted for your account
Assets
USDT on BNB Smart Chain (bsc, BEP20) and TRON (tron, TRC20)
Payment modes
fixed (you set the amount) and any (the user chooses, above a minimum)
Time limit
20 minutes by default; 15–60 minutes per order
Minimum deposit
Platform floor 1 USDT on BSC, 5 USDT on TRON. Your account minimum may be higher; it applies to fixed amounts and is the default minAmount
Payment page
https://mekapay.online/pay/<orderId>, in English and Simplified Chinese
Callbacks
payment.paid, payment.expired, deposit.unmatched, settlement.completed, settlement.rejected, payout.completed, payout.rejected, signed with your API Secret
Payouts (代付)
Send USDT from your balance to your users' wallets with POST /payouts, signed with your own Ed25519 payout key and reviewed by MekaPay before it is sent. Off until MekaPay enables it for your account: see Payouts
Format
JSON over HTTPS. Success: { "success": true, "data": … }; error: { "success": false, "code": …, "message": … }

What you receive at onboarding

When MekaPay onboards your business you receive an onboarding kit with:

  • Merchant code: your account identifier. It is also your login ID for the merchant portal.
  • Merchant portal access at portal.mekapay.online, where you see payments, deposits and callbacks, and set your callback URL. On request, MekaPay can also restrict portal access to IP addresses you list.
  • API Client Key (mpk_…): identifies your requests. It is not secret on its own.
  • API Secret (mps_…): signs your requests and our callbacks. It is shown once. Store it in your server's secret store right away.
  • The API base URL and the address of this documentation.

In return, MekaPay support needs the static public IP addresses of the servers that will call the API. Requests from any other address are refused, even with valid credentials: see IP whitelist.

!

Keep the API Secret on your server. Anyone who has it can create orders in your name and forge callbacks to your system. Never put it in a web page, a mobile app, a repository or a log. If it leaks, ask MekaPay to rotate it: rotation replaces the key and the secret at once and the old pair stops working immediately.

Where to go next

Quickstart

From credentials to a confirmed payment in six steps. Plan about an hour, plus 1 USDT on BSC for the test payment.

Before you start

  • Your onboarding kit with the API Client Key and the API Secret.
  • A server whose public IP address MekaPay support has whitelisted for your account. MekaPay refuses API requests from any other address, even when they are signed correctly, so run the steps below on that server, not on your own computer. See IP whitelist.
  • bash, curl and openssl on that server, for the script in step 3.
  • A wallet with at least 1 USDT on BNB Smart Chain (BEP20) and a little BNB for the network fee.
  1. Store your credentials

    Take the API Client Key and API Secret from your onboarding kit and put them in your server's environment or secret manager. The examples on this site read them from environment variables:

    export MEKAPAY_CLIENT_KEY="mpk_…"   # your API Client Key
    export MEKAPAY_API_SECRET="mps_…"   # your API Secret (server-side only)
  2. Set your callback URL

    In the merchant portal, open Integration and enter the HTTPS URL that should receive callbacks, for example https://api.your-site.example/mekapay/callback. It must be publicly reachable over HTTPS. You can also give the URL to MekaPay during onboarding. See Callbacks.

  3. Check signing with GET /ping

    Run this script on your whitelisted server. It signs requests with openssl and sends them with curl. It first calls /ping, then creates a 1 USDT test order on BSC:

    #!/usr/bin/env bash
    # mekapay-quickstart.sh: sign and send MekaPay requests with curl and openssl
    #   MEKAPAY_CLIENT_KEY=mpk_... MEKAPAY_API_SECRET=mps_... bash mekapay-quickstart.sh
    set -euo pipefail
    HOST="${MEKAPAY_API_HOST:-https://api.mekapay.online}"
    
    mekapay() { # usage: mekapay METHOD PATH [BODY]
      local method="$1" path="$2" body="${3:-}" timestamp signature
      timestamp="$(date +%s)"
      signature="$(printf '%s\n%s\n%s\n%s' "$timestamp" "$method" "$path" "$body" \
        | openssl dgst -sha256 -hmac "$MEKAPAY_API_SECRET" | sed 's/^.*= //')"
      curl -sS -X "$method" "$HOST$path" \
        -H 'Content-Type: application/json' \
        -H "X-MP-Client-Key: $MEKAPAY_CLIENT_KEY" \
        -H "X-MP-Timestamp: $timestamp" \
        -H "X-MP-Signature: $signature" \
        ${body:+--data-binary "$body"}
      echo
    }
    
    # 1. Check your keys and your clock.
    mekapay GET /api/gateway/v1/ping
    
    # 2. Create a 1 USDT fixed-amount order on BSC.
    mekapay POST /api/gateway/v1/orders '{"merchantOrderId":"TEST-0001","userId":"test-user-1","mode":"fixed","chain":"bsc","amount":"1"}'

    A correct setup answers /ping with your merchant code:

    {
      "success": true,
      "data": {
        "merchantCode": "example-merchant",
        "merchantName": "Example Merchant",
        "gatewayEnabled": true,
        "serverTime": "2026-11-11T10:00:00.000Z"
      }
    }

    If you get INVALID_SIGNATURE or TIMESTAMP_OUT_OF_RANGE instead, check common signing mistakes. A 403 with IP_WHITELIST_REQUIRED or IP_NOT_WHITELISTED means the signature is right but the request did not come from a whitelisted address: send the ip value from the response to MekaPay support (IP whitelist).

  4. Create a payment order

    The second call in the script creates a fixed order for 1 USDT. In your own system, create one order per payment attempt and use your own invoice or deposit id as merchantOrderId. Repeating the call with the same merchantOrderId returns the same order, so retries after a timeout are safe.

    {
      "success": true,
      "created": true,
      "data": {
        "orderId": "po_8Kd2Qx7mN4pR1sT6vW9yZ3bC",
        "reference": "MP2611117K2QDM",
        "merchantOrderId": "TEST-0001",
        "status": "pending",
        "chain": "bsc",
        "network": "BEP20",
        "amount": "1",
        "address": "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed",
        "addressType": "single_use",
        "paymentUrl": "https://mekapay.online/pay/po_8Kd2Qx7mN4pR1sT6vW9yZ3bC",
        "expiresAt": "2026-11-11T10:30:00.000Z"
      }
    }

    The full response is documented in Create an order. Node.js, PHP and Python versions are in Code samples.

  5. Pay the order

    Open paymentUrl in a browser. The page shows the amount, the network, a QR code of the address and a countdown. Send exactly 1 USDT on BNB Smart Chain (BEP20) to the address. Within seconds the page switches to Confirming, and to Paid once the transfer is final (about 10 seconds on BSC).

  6. Handle the callback

    MekaPay now sends payment.paid to your callback URL. Your handler must:

    1. verify X-MP-Signature over the raw request body (how);
    2. ignore events whose eventId it has already processed;
    3. credit data.order.amountReceived to data.order.userId at most once per orderId;
    4. answer with any 2xx status within 10 seconds.

    Ready-to-run receivers in Node.js, PHP and Python are in Code samples. Before going live, work through the testing and go-live checklist.

Authentication & signing

Every request to the gateway API is signed with HMAC-SHA256 using your API Secret, and must come from one of your whitelisted server IP addresses. The secret itself is never sent.

Credentials

CredentialFormatUse
Merchant codeyour account id, e.g. example-merchantIdentifies your account in callbacks (merchantCode) and is your portal login ID.
API Client Keympk_ + 24 letters and digitsSent in X-MP-Client-Key on every request. Not secret on its own; it is shown in full in the portal.
API Secretmps_ + 43 characters (URL-safe base64)Key for the request signature and for callback signatures. Shown once when it is issued. Only the last four characters are shown afterwards.
Payout signing keyAn Ed25519 key pair you generate yourselfOnly for payouts: the private key signs payoutSignature on every POST /payouts, in addition to the request signature. You register only the public key, in the portal. See Payout signing keys.

MekaPay issues and rotates credentials on request. Rotation replaces both the client key and the secret immediately; requests signed with the old pair fail with INVALID_CLIENT_KEY, and callbacks are signed with the new secret from then on.

IP whitelist (required)

MekaPay accepts API requests only from the server IP addresses whitelisted for your account. Before your first call, give MekaPay support the static public IPv4 and IPv6 addresses your servers call the API from, or CIDR ranges that contain them. A request from any other address is refused with 403, even when its client key and signature are valid.

!

No whitelisted IP, no API access. Until at least one address is on your whitelist, every API request fails with 403 IP_WHITELIST_REQUIRED. After that, a request from an address that is not on the list fails with 403 IP_NOT_WHITELISTED. Both answers include ip, the address MekaPay received the request from, so you can tell support exactly what to add.

RuleDetails
Applies toEvery request to the signed gateway API (/api/gateway/v1/*, GET /ping included) and to the older client API (/api/client/v1/*).
Not affectedThe hosted payment page your users open, and the callbacks MekaPay sends to your callback URL. Your users can pay from anywhere.
EntriesA single address such as 203.0.113.7 or 2001:db8::7, or a CIDR range: IPv4 /16 or narrower (/16 to /32, e.g. 203.0.113.0/24), IPv6 /32 or narrower (/32 to /128, e.g. 2001:db8:4a00::/48). Up to 50 entries, each with a label such as "Production server".
ChangesMade by MekaPay support only, on your request. The merchant portal shows the list read-only.
Order of checksOn the gateway API the IP is checked after the signature, so a signing problem is reported first as a 401. A 403 IP_… answer means your key and signature are correct.
{
  "success": false,
  "code": "IP_WHITELIST_REQUIRED",
  "message": "No server IP is whitelisted for this merchant. Ask MekaPay support to whitelist your server IP.",
  "ip": "203.0.113.7"
}
{
  "success": false,
  "code": "IP_NOT_WHITELISTED",
  "message": "Requests from 203.0.113.7 are not allowed for this merchant.",
  "ip": "203.0.113.7"
}

The whitelist is an extra lock, not a replacement for the signature: keep the API Secret as safe as before. It also signs the callbacks you receive, and the whitelist does not protect those.

How do I find my server's public IP?

Run these on the server that will call the API, not on your own computer:

curl https://api.ipify.org    # the server's public IPv4 address
curl https://api6.ipify.org   # its public IPv6 address, if it has one

The surest answer comes from MekaPay itself: call GET /ping from the server. Until that address is whitelisted, the ip field of the 403 answer shows exactly the address MekaPay sees; send that value to support. The address must be static. Home and office broadband, and hosting whose outbound address changes, lock you out the moment the address changes.

What about NAT, proxies and load balancers?

  • Whitelist the address your requests leave your network from. Not a private address (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16), and not the address your website is reached at.
  • NAT gateway or firewall: servers in a private network reach the internet through it, so whitelist its public (static or elastic) IP.
  • Outbound HTTP proxy: whitelist the proxy's public IP.
  • Load balancers and CDNs handle traffic coming in to your site. API calls go out from your application servers or their NAT gateway, so whitelist those addresses instead.
  • Several servers, regions or a failover site: whitelist the outbound address of each one, or a range you control.
  • Hosting without a fixed outbound IP (many serverless, container and platform services): send API calls through a NAT gateway or proxy that has a static IP.

Does IPv6 matter?

Yes, if your server has IPv6. HTTP clients usually prefer IPv6 when both ends support it, and then the server's IPv6 address is the one MekaPay checks, even if you only whitelisted its IPv4 address. Either whitelist both addresses, or make your HTTP client connect over IPv4 only (for example curl -4, or CURLOPT_IPRESOLVE set to CURL_IPRESOLVE_V4 in PHP). A server is often given a whole IPv6 prefix and may change its exact address within it; then whitelist the prefix your provider assigned (commonly a /64) rather than one address. The ip field of a 403 answer shows which address family a request used.

How do I change the whitelist?

Ask MekaPay support, with each address or range and a label for it. You cannot change the list yourself, through the API or in the merchant portal. Before you move to a new server, hosting provider or NAT gateway, have the new address added first, switch traffic once GET /ping succeeds from it, and then have the old address removed. The portal's Security page shows your current list; the IP it shows as yours is the computer you are browsing from, which is usually not your server.

Separately, and optionally, MekaPay can restrict the merchant portal to IP addresses you list, for example your office network. Ask support if you want this.

Request headers

HeaderValue
X-MP-Client-KeyYour API Client Key.
X-MP-TimestampCurrent Unix time in seconds, as an integer, e.g. 1794391200. Milliseconds are rejected.
X-MP-SignatureLowercase hex HMAC_SHA256(apiSecret, stringToSign): 64 characters.
Content-Typeapplication/json for requests with a body. A body sent with any other content type (or none) is refused with 415 UNSUPPORTED_MEDIA_TYPE, because MekaPay can only verify the signature over a JSON body it has kept byte for byte.

The string to sign

stringToSign = timestamp + "\n" + METHOD + "\n" + pathWithQuery + "\n" + rawBody
  • timestamp is exactly the value you send in X-MP-Timestamp.
  • METHOD is the HTTP method in upper case: GET or POST.
  • pathWithQuery is the request path starting at /api/, plus the query string exactly as you send it, e.g. /api/gateway/v1/orders?merchantOrderId=INV-10023. It never includes the scheme or the host.
  • rawBody is the exact body bytes you send, read as UTF-8. When there is no body (all GET requests and POST /orders/:orderId/cancel) it is the empty string, so the string to sign ends with a newline.
  • The separators are single line feeds (\n, byte 0x0A), never \r\n.

The signature is the HMAC-SHA256 of stringToSign, keyed with the whole API Secret string (including the mps_ prefix) as UTF-8 bytes, written as lowercase hexadecimal. Do not base64-decode the secret.

Worked example: POST /orders

Use these values to test your implementation. The secret is a published test value, not a real credential.

API Secretmps_AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8
X-MP-Timestamp1794391200
METHODPOST
pathWithQuery/api/gateway/v1/orders
rawBody
{"merchantOrderId":"INV-10023","userId":"player_123","mode":"fixed","chain":"bsc","currency":"USDT","amount":"100.50","expiresInMinutes":30,"returnUrl":"https://merchant.example/deposit/done","metadata":{"cartId":"C-778"}}
stringToSign
1794391200
POST
/api/gateway/v1/orders
{"merchantOrderId":"INV-10023","userId":"player_123","mode":"fixed","chain":"bsc","currency":"USDT","amount":"100.50","expiresInMinutes":30,"returnUrl":"https://merchant.example/deposit/done","metadata":{"cartId":"C-778"}}
X-MP-Signature59e0d2ed6ce46e58fe8f87885dc1100c3c71c36fe4e7d4ecde00ff503c77a394

Worked example: a GET with a query string

X-MP-Timestamp1794391200
METHODGET
pathWithQuery/api/gateway/v1/orders?merchantOrderId=INV-10023
rawBody(empty string)
stringToSign
1794391200
GET
/api/gateway/v1/orders?merchantOrderId=INV-10023
X-MP-Signaturea1b80522211ffc539ab2c081439d6c6ed9fe122a007d023a69502d3bfea31f93

Both examples use the same secret. The Node.js, PHP and Python samples in Code samples produce exactly these signatures when run with this secret at this timestamp.

Clock and replay window

MekaPay rejects a request when X-MP-Timestamp is more than 300 seconds away from its own clock (TIMESTAMP_OUT_OF_RANGE). Keep your servers synchronised with NTP. The serverTime field of GET /ping shows MekaPay's clock if you need to compare.

Common signing mistakes

  • Signing a different body than you send. Serialise the JSON once, sign that string and send that same string. Do not let your HTTP client re-encode an object after you signed it.
  • Leaving out /api/gateway/v1 or including the host in pathWithQuery. Sign /api/gateway/v1/orders, not /orders or https://api.mekapay.online/….
  • Query strings that change after signing. Build the query string yourself, sign it, and request exactly that URL. Parameter order and percent-encoding must be identical.
  • A missing trailing newline on requests without a body. The string to sign still has four parts; the last is empty.
  • Milliseconds in X-MP-Timestamp (Date.now() in JavaScript). Divide by 1000 and drop the fraction.
  • Upper-case hex or base64 output. Send the 64-character lowercase hex digest.
  • A body without Content-Type: application/json. Form-encoded or plain-text bodies are refused with 415 UNSUPPORTED_MEDIA_TYPE before the signature is checked. Requests without a body need no content type.

Test with GET /ping

GET/api/gateway/v1/ping

Returns your account details if the request is signed correctly. It creates nothing and is safe to call at any time.

FieldDescription
merchantCodeYour merchant code.
merchantNameYour business name as shown on the payment page.
gatewayEnabledWhether the payment gateway is switched on for your account.
serverTimeMekaPay's current time (ISO 8601, UTC).

Authentication errors

HTTPCodeMeaning
401MISSING_AUTH_HEADERSOne of the three X-MP-* headers is missing or empty.
401INVALID_CLIENT_KEYThe client key is unknown, revoked or replaced by a rotation.
401TIMESTAMP_OUT_OF_RANGEThe timestamp is malformed or more than 300 seconds from MekaPay's clock.
401INVALID_SIGNATUREThe signature does not match. See the mistakes above.
415UNSUPPORTED_MEDIA_TYPEThe request has a body that was not sent as JSON. Send it with Content-Type: application/json.
403GATEWAY_DISABLEDThe payment gateway is not enabled for your account yet.
403PROJECT_SUSPENDEDYour merchant account is not active. Contact MekaPay.
403IP_WHITELIST_REQUIREDNo server IP is whitelisted for your account yet. ip in the response is the address the request came from. See IP whitelist.
403IP_NOT_WHITELISTEDThe request came from an address (ip in the response) that is not on your account's whitelist.
429RATE_LIMITEDMore than 300 requests in a minute for this client key.

Rate limit

Each client key may make 300 requests per minute. Above that, requests fail with 429 RATE_LIMITED; wait a few seconds and retry with exponential back-off. Prefer callbacks over polling to stay well below the limit.

Response envelope

Every gateway endpoint answers in the same envelope. Branch on success and code; message is for people and may change.

{ "success": true, "data": { … } }
{ "success": false, "code": "INVALID_SIGNATURE", "message": "Signature does not match" }

Payment modes

Choose the mode per order in the mode field: fixed when you know the amount, any when the user decides how much to send.

Fixed amount and any amount compared

fixedany
amountRequiredMust be absent or null
minAmountMust be absent or nullOptional; defaults to your account minimum for the chain. A value you send only has to clear the platform floor
Your account minimumamount must be at least thisThe default minAmount
The order is paid whenConfirmed transfers inside the order window add up to at least amountThe first confirmed transfer of at least minAmount inside the window arrives
Several transfersAdd up (the user can pay in parts)The first qualifying transfer pays; later ones are unmatched (order_already_paid)
A transfer below the minimumCounts toward the totalUnmatched (below_minimum); the order keeps waiting
More than requiredPaid, with overpaid: truePaid; amountReceived is what was sent
Payment page showsThe exact amount, what is left to pay, a copy button"Any amount · minimum X USDT"
Typical useCheckout, invoices, fixed top-up packagesWallet or balance top-ups

Fixed amount

Send amount as a decimal string with at most 6 decimal places, for example "100.50". It must be at least your account minimum for the chain and at most 1000000. The account minimum is the platform floor (1 USDT on BSC, 5 USDT on TRON) unless a higher minimum was agreed for your account; a lower amount is refused with 400 AMOUNT_BELOW_MINIMUM. MekaPay stores and returns amounts normalised, so "100.50" comes back as "100.5".

The payment page asks for exactly that amount. MekaPay adds up every confirmed transfer to the address inside the order window:

  • Exact or more: the order becomes paid. If the total is greater than amount, overpaid is true and amountReceived shows the total.
  • Less: the order stays pending and the page shows the remaining balance. The user can send the rest before the time limit.
  • Time runs out with a partial payment: the order becomes expired with underpaid: true. The funds have arrived; decide with the user whether to credit the partial amount. The payment page tells the user to contact you with the order reference. If a transfer sent in time confirms late and completes the order, you then receive payment.paid for the same order, with amountReceived covering the partial amount too: see Crediting rule.
  • A transfer sent in time confirms after expiry: if it completes the amount, the order still becomes paid and you receive payment.paid. If it does not, it is reported on its own as deposit.unmatched (after_expiry). See Late transfers.
!

Exchange withdrawals often arrive short. Many exchanges deduct their withdrawal fee from the amount sent. In fixed mode the order then stays pending until the user sends the difference. Ask users to make sure the amount received covers the order.

Any amount

Leave out amount. Optionally set minAmount. Without it, the order uses your account minimum for the chain. A minAmount you send only has to be at least the platform floor (1 USDT on BSC, 5 USDT on TRON), even if your account minimum is higher, so you can accept smaller top-ups on a particular order. The first confirmed transfer of at least minAmount pays the order, and amountReceived holds the amount actually sent: credit that amount.

A transfer below minAmount does not pay the order. MekaPay reports it with a deposit.unmatched callback (unmatchedReason: "below_minimum") and the order keeps waiting for a qualifying transfer until it expires.

Which mode should I use?

  • Use fixed when the payment settles a specific price: an invoice, a basket, a package. You know exactly what was owed and what arrived.
  • Use any for "deposit to your balance" flows where users choose the amount in their wallet. It avoids failed payments caused by fees deducted on the way.

Both modes use the same address rules, the same time limit rules and the same callbacks. See Order lifecycle & statuses.

Order lifecycle & statuses

An order is active while it waits for money, and ends paid, expired or cancelled. Each transfer into the address is tracked separately as a deposit.

Order statuses

StatusMeaningFinal?
pendingWaiting for the user's transfer. Also the status of a fixed-amount order that has received part of the amount.No (active)
confirmingA transfer to the address has been seen on-chain and is waiting for confirmations.No (active)
paidConfirmed funds satisfy the order, or MekaPay staff approved it by hand (see Manual approval). MekaPay sends payment.paid.Yes
expiredThe time limit passed without payment. MekaPay sends payment.expired.Almost: it can still become paid if a transfer sent in time confirms late and completes it
cancelledCancelled by you, replaced by a newer order for the same user and chain, or cancelled by MekaPay staff (cancelReason: merchant, superseded, staff).Almost: it can still become paid if a transfer sent before the cancellation confirms afterwards and completes it

Transitions

Order status transitions pendingactive confirmingactive paidfinal cancelledlate pay possible expiredlate pay possible transfer seen partial or dropped confirmed cancel / superseded time limit over late confirmation
A fixed-amount order goes back to pending when a confirmed transfer covers only part of the amount. Dashed lines: a transfer sent before the order expired or was cancelled that confirms afterwards and completes the order.
FromToWhenCallback
pendingconfirmingA transfer to the address is detected inside the order window.none
confirmingpaidThe transfer is final and the confirmed total satisfies the order.payment.paid
confirmingpendingThe confirmed transfer was only part of a fixed amount, or the transfer was dropped from the chain.none
pendingexpiredAt the earliest 180 seconds after expiresAt, once MekaPay has read the chain up to expiresAt + 120 seconds and no transfer is still confirming.payment.expired
pendingcancelledYou cancel it, a new order for the same user and chain replaces it, or MekaPay staff cancel it.none
expiredpaidA transfer sent inside the window confirms after the order expired and completes it.payment.paid
cancelledpaidA transfer sent before the cancellation confirms afterwards and completes the order.payment.paid
pending or expiredpaidMekaPay staff approve the order by hand. See Manual approval.payment.paid

The time window

Each order has a time limit: expiresInMinutes when you create it (15–60), or your account default (20 minutes for new accounts). Users copy the address, open a wallet and sometimes withdraw from an exchange, which can take 5–20 minutes; choose a longer time limit if many of your users pay from exchanges. MekaPay is lenient at the edges so that a payment sent in time is never lost:

  • The transfer's block time decides. A transfer belongs to an order when its block time is between createdAt and expiresAt + 120 seconds, however late MekaPay sees or confirms it.
  • The order switches to expired at the earliest 180 seconds after expiresAt, and only once MekaPay's chain scanner has read every block up to expiresAt + 120 seconds, and not while a transfer for it is still confirming. If the scanner is catching up, payment.expired comes a little later than expiresAt + 3 minutes; it never comes before a transfer sent in time has been seen.
  • A transfer sent inside the window that confirms after expiry still pays the order (expired → paid) if it completes it, and you receive payment.paid after the earlier payment.expired. See Late transfers.

Late transfers

Sometimes a transfer that belongs to an order is confirmed only after the order has already ended: the order expired while the transfer was still on its way, or you cancelled the order just after the user sent the money. MekaPay then decides by the transfer's block time:

OrderTransferResult
expiredBlock time inside the window, and the order's confirmed total plus this transfer satisfies the order (fixed: reaches amount; any: this transfer is at least minAmount)The order becomes paid: payment.paid, after the earlier payment.expired
expiredBlock time inside the window, but the order is still not satisfieddeposit.unmatched with after_expiry (or below_minimum for a transfer below an any-amount order's minAmount); the order stays expired
cancelledBlock time before cancelledAt, and it satisfies the order as aboveThe order becomes paid: payment.paid
cancelledBlock time before cancelledAt but the order is not satisfied, or block time after cancelledAtdeposit.unmatched with order_cancelled; the order stays cancelled
paidAny further transfer, also after a manual approvaldeposit.unmatched with order_already_paid

An order that has ended never becomes partly paid by a late transfer: the transfer either completes it, or you are told about it separately. Every confirmed deposit is reported to you: either inside a payment.paid or payment.expired callback (in order.deposits), or in its own deposit.unmatched callback. A partial deposit of an expired order appears in payment.expired and again in a later payment.paid if a late transfer completes the order, so credit by order total (see Crediting rule).

Manual approval by MekaPay staff

MekaPay staff can mark a pending or expired order as paid by hand, for example so that your integration tests can reach paid, or after a support decision on an underpaid order. An order can be approved once, and not while a transfer for it is still confirming.

  • You receive a normal signed payment.paid callback. Its deposit is null, because no transfer completed the order.
  • amountReceived is the approved total. manualApproval.amountOnChain is what had actually arrived on-chain when the order was approved (it can be 0); the approved total is never less than that.
  • deposits lists only real transfers.
  • Credit by the Crediting rule as usual: amountReceived minus what you already credited for that orderId. If you credited a partial amount on payment.expired, you credit only the difference.
  • A transfer that arrives later for that order is reported on its own as deposit.unmatched with order_already_paid, and never changes amountReceived.
  • paidAt is the time of the approval. An approved expired order keeps its expiredAt.

A manually approved order carries a manualApproval object; the key is absent on every other order. An expired fixed-amount order that had received 40 of 100.5 USDT, approved at the full amount (excerpt):

{
  "orderId": "po_8Kd2Qx7mN4pR1sT6vW9yZ3bC",
  "status": "paid",
  "amount": "100.5",
  "amountReceived": "100.5",
  "overpaid": false,
  "underpaid": false,
  "paidAt": "2026-11-11T11:02:40.000Z",
  "expiredAt": "2026-11-11T10:33:04.000Z",
  "manualApproval": {
    "approvedAt": "2026-11-11T11:02:40.000Z",
    "amount": "100.5",
    "amountOnChain": "40"
  },
  "deposits": [ { "depositId": "6650a1f2c3d4e5f6a7b8c9d0", "amount": "40", "…": "…" } ]
}

Deposit addresses

Every order has a deposit address, and addressType says which kind it is:

addressTypeMeaning
single_useThe default. The address comes from your account's address pool and belongs to this order only, until its time limit ends. Show it for this payment only and never save it for the user: the next order gets an address of its own.
permanentThe user's own address on this chain, the same for every order. Users who were given a permanent address before the address pool existed keep it, and accounts set up for permanent addresses give every user one.

How a single_use address is handled:

  • While the order is open, the address is reserved for it: every transfer whose block time is inside the order's window (createdAt to expiresAt plus 2 minutes) belongs to that order.
  • After the window the address rests for 60 minutes and is given to nobody else. A transfer that arrives while it rests still belongs to that order, but it is never credited automatically: you receive deposit.unmatched with the order and the user (after_expiry, or order_already_paid if the order was paid, or order_cancelled), and MekaPay support can review it.
  • After the rest the address may be given to another order, possibly for another user. A transfer that arrives that late cannot be traced to the earlier order, so tell users to pay only while the payment page shows the timer.
  • A new order for the same user while their previous order is still open reuses the same address (see below). A user who pays again soon after an order usually gets the same address back.

With a permanent address, users may send money to it again later without creating an order first. Such transfers are still detected and reported to you as deposit.unmatched with unmatchedReason: "no_active_order" and the user's userId; see unmatched deposits.

One active order per address

An address can have only one active (pending or confirming) order at a time, so every transfer maps to exactly one order. When you create a new order for a user and chain that already has an active order (on the same address):

  • if no transfer has been detected for the active order, it is cancelled with cancelReason: "superseded" and the new order is created;
  • otherwise the request fails with 409 ACTIVE_ORDER_IN_PROGRESS, and the response includes the orderId of the order that is waiting for its transfer. Wait for that order to finish, or show it to the user again.

A retry of the same create request never replaces its own order: an active order with the same merchantOrderId is not superseded, even when two copies of the request arrive at the same moment. See Idempotency and retries.

Deposits

Every USDT transfer into a MekaPay deposit address becomes a deposit with its own status:

Deposit statusMeaning
detectedSeen on-chain, waiting for confirmations (confirmations of requiredConfirmations).
confirmedFinal. Only confirmed deposits count toward an order.
orphanedThe transfer dropped out of the chain (re-organisation) or could not be verified. Never counted.
failedThe transaction failed on-chain. Never counted.

Once a deposit is confirmed, MekaPay matches it to an order: matchStatus is pending until then, and matched or unmatched afterwards.

Unmatched deposits

A confirmed deposit that cannot pay an order is unmatched. MekaPay still receives the funds, records the deposit and sends you a deposit.unmatched callback so you can decide what to do, for example credit the user manually after a support check.

unmatchedReasonWhy
no_active_orderNo order for this address covered the transfer's block time: it was sent before any order, after the window closed, or long after. For a single_use address this means after its 60-minute rest; such a deposit has no order and no userId.
below_minimumAny-amount order, and the transfer was smaller than minAmount.
after_expiryThe transfer was sent inside the window of an order that has since expired, and it did not complete that order (see Late transfers), or it reached a single_use address during its 60-minute rest after that order.
order_cancelledThe order that covered the transfer was cancelled: the transfer was sent after the cancellation, or sent before it without completing the order.
order_already_paidThe order was already paid; this is an extra transfer, for example a second payment of the same amount (also during a single_use address's rest).

The callback includes the related order when there is one (for example the expired or cancelled order), else order is null. Treat any reason you do not recognise as unmatched; new reasons may be added. Your agreed fee applies to every confirmed deposit, matched or unmatched.

Create an order

Creates a payment order for one of your users and returns the deposit address and the payment link.

POST/api/gateway/v1/orders

Request body

FieldRules
merchantOrderIdstring

requiredYour id for this payment, unique within your account. 1–64 characters: letters, digits and . _ : - (^[A-Za-z0-9._:\-]{1,64}$).

userIdstring

requiredYour id for the paying user. 1–64 characters: letters, digits and . _ : @ - (^[A-Za-z0-9._:@\-]{1,64}$). Each order gets a single-use address, except for a user who already has a permanent address on that chain (see Deposit addresses). Ids starting with staff-test- are reserved for MekaPay test orders and are refused with 400 VALIDATION_ERROR.

modestring

requiredfixed or any. See Payment modes.

chainstring

requiredbsc (BNB Smart Chain, BEP20) or tron (TRON, TRC20).

currencystring

optionalMust be USDT if sent.

amountstring

fixed: requiredDecimal string with at most 6 decimals, at least your account minimum for the chain (the platform floor of 1 USDT BSC / 5 USDT TRON, or the higher minimum agreed for your account), at most 1000000. Must be absent or null in any mode.

minAmountstring | null

any: optionalSmallest transfer that pays the order. Defaults to your account minimum for the chain. A value you send only has to be at least the platform floor (1 USDT BSC, 5 USDT TRON), even if your account minimum is higher. Must be absent or null in fixed mode.

expiresInMinutesinteger

optional15–60. Defaults to your account setting (30).

returnUrlstring

optionalhttps:// URL, at most 512 characters. The payment page offers a "Return to merchant" button to it once the order is paid, expired or cancelled.

metadataobject

optionalAny JSON object up to 2048 bytes when serialised. Returned unchanged in the order and in callbacks. It is never shown on the payment page.

Example request

{
  "merchantOrderId": "INV-10023",
  "userId": "player_123",
  "mode": "fixed",
  "chain": "bsc",
  "currency": "USDT",
  "amount": "100.50",
  "expiresInMinutes": 30,
  "returnUrl": "https://merchant.example/deposit/done",
  "metadata": { "cartId": "C-778" }
}

An any-amount order on TRON with a 10 USDT minimum:

{
  "merchantOrderId": "DEP-55120",
  "userId": "player_123",
  "mode": "any",
  "chain": "tron",
  "minAmount": "10"
}

Idempotency and retries

merchantOrderId is the idempotency key. If you send it again:

  • with the same userId, mode, chain, amount and minAmount, you get 200 with the existing order and "created": false, whatever its status is now;
  • with any of those fields different, you get 409 DUPLICATE_MERCHANT_ORDER_ID.

This also holds when two copies of the same request arrive at the same moment (for example a client retry racing the original): one order is created, the other copy gets it back with "created": false (or 409 DUPLICATE_MERCHANT_ORDER_ID if the details differ). A retry never supersedes its own order.

So after a timeout or a network error, retry the same request with the same merchantOrderId: you never create two orders for one payment. Use a new merchantOrderId for each new payment attempt.

Response

201 Created for a new order, 200 OK with "created": false for an existing one.

{
  "success": true,
  "created": true,
  "data": {
    "orderId": "po_8Kd2Qx7mN4pR1sT6vW9yZ3bC",
    "reference": "MP2611117K2QDM",
    "merchantOrderId": "INV-10023",
    "userId": "player_123",
    "mode": "fixed",
    "status": "pending",
    "chain": "bsc",
    "network": "BEP20",
    "currency": "USDT",
    "tokenContract": "0x55d398326f99059fF775485246999027B3197955",
    "decimals": 18,
    "amount": "100.5",
    "minAmount": null,
    "amountReceived": "0",
    "overpaid": false,
    "underpaid": false,
    "address": "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed",
    "addressType": "single_use",
    "paymentUrl": "https://mekapay.online/pay/po_8Kd2Qx7mN4pR1sT6vW9yZ3bC",
    "requiredConfirmations": 15,
    "expiresAt": "2026-11-11T10:30:00.000Z",
    "createdAt": "2026-11-11T10:00:00.000Z",
    "paidAt": null,
    "expiredAt": null,
    "cancelledAt": null,
    "cancelReason": null,
    "returnUrl": "https://merchant.example/deposit/done",
    "metadata": { "cartId": "C-778" },
    "deposits": []
  }
}

The order object

The same object is returned by every order endpoint and sent in callbacks.

FieldDescription
orderIdMekaPay's id: po_ + 24 random letters and digits. Unguessable, because it appears in the public payment link.
referenceShort reference for people: MP + date (yyMMdd, UTC) + 6 characters, e.g. MP2611117K2QDM. Shown on the payment page; quote it to MekaPay support.
merchantOrderId, userIdYour values, as sent.
testPresent and true only on a test order that MekaPay staff created for your account with the console's Test Payment tool (absent on every order you create). Test orders send callbacks only when staff choose to; you can recognise them by this field and by their merchantOrderId starting with TEST-.
modefixed or any.
statuspending, confirming, paid, expired or cancelled. See Order lifecycle.
chain, network, currencybsc / BEP20 or tron / TRC20; always USDT.
tokenContract, decimalsThe USDT contract on that chain and its on-chain decimals (18 on BSC, 6 on TRON).
amountFixed mode: the amount due, as a normalised decimal string. Any mode: null.
minAmountAny mode: the smallest transfer that pays the order. Fixed mode: null.
amountReceivedSum of the confirmed transfers matched to this order, as a decimal string. On BSC it can have up to 18 decimals. On an order MekaPay staff approved by hand, the approved total (see manualApproval).
overpaidFixed mode: true when amountReceived is greater than amount.
underpaidtrue when the order expired after receiving some, but not enough, confirmed funds.
addressThe deposit address: EIP-55 checksummed 0x… on BSC, Base58 T… on TRON.
addressTypesingle_use (valid for this order only, the default) or permanent (the user's own address). See Deposit addresses.
paymentUrlThe hosted payment page: https://mekapay.online/pay/<orderId>.
requiredConfirmationsConfirmations a transfer needs: 15 on BSC, 19 on TRON. See Chains.
createdAt, expiresAtISO 8601 timestamps in UTC.
paidAt, expiredAt, cancelledAtWhen the order reached that status, or null. An order paid by a late transfer keeps its expiredAt or cancelledAt.
cancelReasonmerchant, superseded, staff or null.
returnUrl, metadataYour values, or null.
manualApprovalPresent only on an order MekaPay staff marked as paid by hand (absent on every other order): approvedAt (ISO 8601), amount (the approved total, also in amountReceived) and amountOnChain (what had actually arrived on-chain when it was approved). See Manual approval.
depositsThe transfers linked to this order (deposit objects), oldest first. Left out in list responses. Only real transfers, also on a manually approved order.

Errors

HTTPCodeWhen
400VALIDATION_ERRORA field is missing or malformed. The response names it in field.
400INVALID_AMOUNTamount or minAmount is not a positive decimal with at most 6 decimals, or is above 1,000,000.
400AMOUNT_BELOW_MINIMUMFixed mode: amount is below your account minimum. Any mode: minAmount is below the platform floor. The response names the field and the minimum.
409DUPLICATE_MERCHANT_ORDER_IDThe merchantOrderId exists with different order details.
409ACTIVE_ORDER_IN_PROGRESSThe user already has an active order on this chain with a detected transfer. The response includes its orderId.
503POOL_EXHAUSTEDNo deposit address is free for a new user on this chain. Retry later; MekaPay is alerted automatically.
{
  "success": false,
  "code": "VALIDATION_ERROR",
  "message": "amount is required when mode is fixed",
  "field": "amount"
}
{
  "success": false,
  "code": "ACTIVE_ORDER_IN_PROGRESS",
  "message": "This user has an order waiting for its transfer to confirm",
  "orderId": "po_3hT9wQ2mZ7xK5cV1bN8rL4dF"
}

The authentication errors on Authentication & signing apply to every endpoint.

Retrieve / list orders

Read one order by MekaPay's id or by yours, or page through your orders.

Retrieve an order

GET/api/gateway/v1/orders/:orderId

Returns the order object, including its deposits. Answers 404 ORDER_NOT_FOUND when the order does not exist or belongs to another merchant.

Find an order by your id

GET/api/gateway/v1/orders?merchantOrderId=INV-10023

Returns the order with that merchantOrderId, or 404 ORDER_NOT_FOUND. Useful when a create request timed out and you are not sure whether it reached MekaPay (or simply retry the create; it is idempotent).

List orders

GET/api/gateway/v1/orders
Query parameterDescription
statuspending, confirming, paid, expired or cancelled.
chainbsc or tron.
userIdOnly this user's orders.
from, toCreation time range, ISO 8601, e.g. 2026-11-01T00:00:00Z.
pagePage number, starting at 1. Default 1.
limitItems per page, at most 100. Default 25.

Remember to sign the query string exactly as you send it, for example /api/gateway/v1/orders?status=paid&chain=bsc&page=2.

{
  "success": true,
  "data": [
    {
      "orderId": "po_8Kd2Qx7mN4pR1sT6vW9yZ3bC",
      "reference": "MP2611117K2QDM",
      "merchantOrderId": "INV-10023",
      "userId": "player_123",
      "mode": "fixed",
      "status": "paid",
      "chain": "bsc",
      "amount": "100.5",
      "amountReceived": "100.5",
      "paidAt": "2026-11-11T10:05:02.000Z",
      "…": "every other order field except deposits"
    }
  ],
  "pagination": { "page": 1, "limit": 25, "total": 1, "pages": 1 }
}

Lists leave out deposits; fetch a single order to see them.

Polling

Callbacks tell you about every status change you need to act on. If you also poll, for example to show live progress in your own interface, poll a single order no more often than every 5–10 seconds, stop when it reaches a final status, and stay within the rate limit. Use the list endpoint for reconciliation, not for real-time updates.

Cancel an order

Stops an active order that has not received a transfer, for example when the user abandons checkout.

POST/api/gateway/v1/orders/:orderId/cancel

Request

No request body. Sign it with an empty rawBody, so the string to sign ends with a newline:

1794391200
POST
/api/gateway/v1/orders/po_8Kd2Qx7mN4pR1sT6vW9yZ3bC/cancel

Rules

  • Only an active order (pending or confirming) with no detected or confirmed transfer can be cancelled.
  • Otherwise the request fails with 409 ORDER_NOT_CANCELLABLE: the order is already final, or money is on its way. Let it finish.
  • Cancellation does not send a callback; the response is your confirmation.
  • You do not need to cancel before creating a new order for the same user and chain: the new order replaces an untouched active one automatically (cancelReason: "superseded").
!

The address stays the user's. If the user pays after you cancelled, the transfer is still detected and reported as deposit.unmatched with unmatchedReason: "order_cancelled". Tell users not to pay a cancelled order.

A transfer the user sent before you cancelled, which MekaPay had not seen yet, still counts: if it completes the order, the order becomes paid and you receive payment.paid; otherwise it is reported as order_cancelled. See Late transfers.

Response

The cancelled order object:

{
  "success": true,
  "data": {
    "orderId": "po_8Kd2Qx7mN4pR1sT6vW9yZ3bC",
    "status": "cancelled",
    "cancelledAt": "2026-11-11T10:07:41.000Z",
    "cancelReason": "merchant"
  }
}
HTTPCodeWhen
404ORDER_NOT_FOUNDNo such order for your account.
409ORDER_NOT_CANCELLABLEThe order is final, or a transfer has been detected for it.

List deposits

Every USDT transfer into your users' deposit addresses, matched to an order or not. Use it for reconciliation and to review unmatched deposits.

GET/api/gateway/v1/deposits

Query parameters

ParameterDescription
statusdetected, confirmed, orphaned or failed. Default confirmed.
matchStatuspending, matched or unmatched.
userIdOnly this user's deposits.
from, toTime range, ISO 8601.
page, limitPage number from 1; at most 100 per page, default 25.
{
  "success": true,
  "data": [
    {
      "depositId": "8277e0910d750195b4487976",
      "chain": "tron",
      "txHash": "02a2fc5ce4e87fabe6d589e85f801cd7b8a296208a96cae9f3f89e0e6f28e565",
      "logPos": 0,
      "from": "TV311EvMxfXQzVbJFNbBvtyH1XjKp6qswU",
      "to": "TW28meJ4JPqGG8GeV9tTykaep8WQhvUNwk",
      "amount": "25",
      "status": "confirmed",
      "confirmations": 19,
      "requiredConfirmations": 19,
      "blockNumber": 76543210,
      "blockTimestamp": "2026-11-11T12:41:03.000Z",
      "detectedAt": "2026-11-11T12:41:09.000Z",
      "confirmedAt": "2026-11-11T12:42:06.000Z",
      "matchStatus": "unmatched",
      "unmatchedReason": "no_active_order",
      "orderId": null,
      "explorerUrl": "https://tronscan.org/#/transaction/02a2fc5ce4e87fabe6d589e85f801cd7b8a296208a96cae9f3f89e0e6f28e565",
      "userId": "player_123"
    }
  ],
  "pagination": { "page": 1, "limit": 25, "total": 1, "pages": 1 }
}

The deposit object

FieldDescription
depositIdMekaPay's id for the transfer.
chainbsc or tron.
txHashTransaction hash in lower case: 0x + 64 hex on BSC, 64 hex without a prefix on TRON.
logPosPosition of the transfer inside its transaction. One transaction can pay several addresses, so (chain, txHash, logPos) identifies a deposit.
from, toSender address and the user's deposit address.
amountUSDT received, as a decimal string.
statusdetected, confirmed, orphaned or failed. See Deposits.
confirmations, requiredConfirmationsProgress toward finality (15 on BSC, 19 on TRON).
blockNumber, blockTimestampBlock that contains the transfer and its time. The block time decides which order the transfer belongs to.
detectedAt, confirmedAtWhen MekaPay saw the transfer and when it became final.
matchStatus, unmatchedReasonWhether the deposit paid an order; see unmatched deposits.
orderIdThe order it is linked to, or null.
explorerUrlLink to the transaction on BscScan or Tronscan.
userIdThe user who owns the address. Included in this list and in deposit.unmatched callbacks.

Reconciliation

  • Once a day, list the previous day's confirmed deposits and check that each matched one is credited in your system exactly once.
  • List matchStatus=unmatched to find money that arrived without a payable order, and resolve each case with the user.
  • The merchant portal shows the same data under Deposits, with an unmatched filter.

Balance

What MekaPay holds for you on each chain: your confirmed deposits minus the deposit fee, minus your settlements and payouts. You withdraw it with a settlement, or pay it out to your users with payouts.

GET/api/gateway/v1/balance

How the balance is kept

  • One balance per chain. USDT on BNB Smart Chain (BEP20) and on TRON (TRC20) are kept apart and never converted into each other.
  • Every confirmed deposit is credited, matched to an order or not (an unmatched deposit is still your users' money), minus your deposit fee. The fee is your fee percentage of the amount, rounded down to the token's smallest unit (BEP20: 18 decimals, TRC20: 6).
  • An order MekaPay staff approved by hand adds nothing to the balance: only money that arrived on-chain is credited.
  • A settlement request is deducted from the balance at once and shows in pendingSettlements until it is completed. A cancelled or rejected settlement is credited back in full.
  • A payout deducts its totalAmount (the amount plus the fee) at once and shows in pendingPayouts until it is completed. A cancelled or rejected payout is credited back in full.
  • The ledger behind the balance is append-only and every entry is chained to the previous one by a hash, so a changed or missing entry is detected. MekaPay verifies every ledger daily and after every settlement.

Request

No query parameters. Sign it like any GET (empty body).

Response

{
  "success": true,
  "data": {
    "settlementsEnabled": true,
    "payoutsEnabled": true,
    "payoutLimits": {
      "maxPerPayout": "5000",
      "maxPerDay": "20000",
      "maxPerAddressPerDay": "5000"
    },
    "payoutUsedLast24h": "1250",
    "balances": [
      {
        "chain": "bsc",
        "network": "BEP20",
        "currency": "USDT",
        "balance": "1520.75",
        "pendingSettlements": "200",
        "settlementFee": "1",
        "settlementMinimum": "10",
        "pendingPayouts": "0",
        "payoutFee": "1",
        "payoutMinimum": "5"
      },
      {
        "chain": "tron",
        "network": "TRC20",
        "currency": "USDT",
        "balance": "8030.4",
        "pendingSettlements": "0",
        "settlementFee": "3",
        "settlementMinimum": "10",
        "pendingPayouts": "253",
        "payoutFee": "3",
        "payoutMinimum": "5"
      }
    ]
  }
}
FieldMeaning
settlementsEnabledbooleanWhether you can request settlements. Off until MekaPay turns it on for your account.
payoutsEnabledbooleanWhether you can request payouts. Off until MekaPay turns it on for your account.
payoutLimitsobjectYour payout limits in USDT: maxPerPayout, maxPerDay (both chains together) and maxPerAddressPerDay, the 24-hour ones over a rolling window. See Limits.
payoutUsedLast24hstringHow much of maxPerDay your payouts of the last 24 hours use (every payout except rejected and cancelled ones).
balancestringWhat you can settle or pay out now, in USDT. Settlements and payouts in progress are already deducted.
pendingSettlementsstringThe total of your settlements that are requested, approved or being verified.
pendingPayoutsstringThe total (totalAmount, amount plus fee) of your payouts that are requested, approved or being verified.
settlementFeestringThe fixed fee per settlement on this chain, deducted from the settled amount.
settlementMinimumstringThe smallest settlement on this chain.
payoutFeestringThe fixed fee per payout on this chain, charged on top of the amount.
payoutMinimumstringThe smallest payout amount on this chain.

All amounts are decimal strings. The same balance is shown in the merchant portal under Balance & Settlements and Payouts.

Settlement addresses

Your own wallets that settlements are paid to. You add them in the merchant portal with your two-factor code, MekaPay approves them, and they can receive settlements 48 hours after approval.

GET/api/gateway/v1/settlement-addresses

Adding an address

  1. Turn on two-factor authentication for the merchant portal (Security).
  2. In the portal, open Balance & Settlements → Settlement addresses and add the chain, the address, an optional label (up to 60 characters) and the current code from your authenticator app.
  3. MekaPay reviews the address and approves or rejects it. Approval needs a MekaPay staff member's own two-factor code.
  4. The address is cooling for 48 hours after approval, then active: only active addresses can receive settlements.
i

Why addresses are locked. Addresses cannot be added through the API, and MekaPay staff cannot add them for you. Even if your API credentials leaked, money could only ever be sent to wallets you added yourself with your two-factor code, and every new wallet waits 48 hours after approval, so there is time to notice and remove one you did not add.

Rules

  • The address must be valid for the chain: a 0x… BNB Smart Chain address (lowercase or with a correct EIP-55 checksum) or a TRON T… address. It is stored in its checksummed / Base58 form.
  • It must be a wallet you control: MekaPay deposit addresses, the zero address and the USDT token contract are refused.
  • At most 3 addresses per chain that are pending approval or approved (cooling or active). Rejected and removed addresses do not count, and the same address cannot be added twice while it is pending or approved.
  • You can remove an address at any time in the portal, with your two-factor code. MekaPay can also remove one, with a reason. Removing an address does not change settlements you already requested to it.

States

stateMeaning
pending_approvalAdded, waiting for MekaPay's review.
coolingApproved; usable from activatesAt, 48 hours after approval.
activeCan receive settlements.
rejectedNot approved; see rejectReason. Remove it or add another address.
removedRemoved by you or by MekaPay; listed only when you ask for it.

status is the stored status (pending_approval, approved, rejected or removed); state splits approved into cooling and active. Use state.

List your addresses

Query parameterDescription
chainbsc or tron.
statusA state from the table above, approved (cooling or active), or all. Without it, every address except removed ones.
{
  "success": true,
  "data": [
    {
      "id": "6731f0c2a9e4b1d2c3f40512",
      "chain": "tron",
      "network": "TRC20",
      "currency": "USDT",
      "address": "TPRgcsJLsCUpYcCwiiXxxQYM4rof95eDVZ",
      "label": "Main TRON wallet",
      "status": "approved",
      "state": "active",
      "requestedAt": "2026-11-08T09:12:40.000Z",
      "approvedAt": "2026-11-08T11:30:02.000Z",
      "activatesAt": "2026-11-10T11:30:02.000Z",
      "rejectedAt": null,
      "rejectReason": null,
      "removedAt": null,
      "removedBy": null
    }
  ]
}

Use id as settlementAddressId when you request a settlement.

Settlements

Withdraw your balance to one of your active settlement addresses. MekaPay reviews every settlement, sends the USDT and confirms the transfer on-chain before it is completed.

Fees and limits

ChainFee per settlementMinimum settlement
BNB Smart Chain (BEP20)1 USDT10 USDT
TRON (TRC20)3 USDT10 USDT
  • The fee is deducted from the settled amount: you request amount, it is taken from your balance, and netAmount = amount − fee arrives at your address. A 100 USDT settlement on TRON sends 97 USDT.
  • amount is a decimal string with at most 6 decimals and at most your balance on that chain.
  • Settlements must be turned on for your account by MekaPay; until then every request is refused with 403 SETTLEMENTS_DISABLED (GET /balance shows settlementsEnabled).
  • The destination must be one of your settlement addresses in state active.

Request a settlement

POST/api/gateway/v1/settlements
FieldRules
chainstring

requiredbsc or tron.

amountstring

requiredDecimal string, at most 6 decimals, at least the chain's minimum and more than its fee.

settlementAddressIdstring

requiredThe id of one of your active settlement addresses on the same chain.

merchantSettlementIdstring

optionalYour id for this settlement, unique within your account, 1–64 characters of letters, digits and . _ : -. Makes retries safe (see below).

{
  "chain": "tron",
  "amount": "100",
  "settlementAddressId": "6731f0c2a9e4b1d2c3f40512",
  "merchantSettlementId": "WD-20261111-001"
}
{
  "success": true,
  "created": true,
  "data": {
    "settlementId": "st_4Gm8Qx2Lr7Vt1Zp9Kc3Wd6Yb",
    "reference": "MPS261111K7QD2M",
    "merchantSettlementId": "WD-20261111-001",
    "chain": "tron",
    "network": "TRC20",
    "currency": "USDT",
    "amount": "100",
    "fee": "3",
    "netAmount": "97",
    "address": "TPRgcsJLsCUpYcCwiiXxxQYM4rof95eDVZ",
    "addressLabel": "Main TRON wallet",
    "settlementAddressId": "6731f0c2a9e4b1d2c3f40512",
    "status": "requested",
    "requestedVia": "api",
    "createdAt": "2026-11-11T10:20:31.000Z",
    "approvedAt": null,
    "completedAt": null,
    "rejectedAt": null,
    "rejectReason": null,
    "cancelledAt": null,
    "txHash": null,
    "explorerUrl": null
  }
}

The amount leaves your balance immediately. If your balance is too low the request is refused with 409 INSUFFICIENT_BALANCE, whose available field (and message) says how much you can settle; nothing is created.

Idempotency and retries

Send a merchantSettlementId and retry freely: the same id with the same chain, amount and settlementAddressId answers 200 with the existing settlement and "created": false; the same id with other details answers 409 DUPLICATE_MERCHANT_SETTLEMENT_ID. Find a settlement by your id with GET /settlements?merchantSettlementId=….

Statuses

StatusMeaning
requestedReceived and deducted from your balance; waiting for MekaPay's approval. You can still cancel it.
approvedApproved by MekaPay; the transfer is being prepared.
verifyingThe transfer has been sent and MekaPay is confirming it on-chain.
completedFinal: netAmount arrived at your address in txHash. You receive settlement.completed.
rejectedFinal: MekaPay rejected it (rejectReason); the amount is back on your balance. You receive settlement.rejected.
cancelledFinal: you cancelled it; the amount is back on your balance.

A transfer is completed only after MekaPay has read it from the chain itself: the transaction succeeded, it is final (BNB Smart Chain: in a finalized block; TRON: in a solidified block), and it pays exactly netAmount of USDT to your settlement address. txHash and explorerUrl are filled in once the settlement is completed.

Cancel a settlement

POST/api/gateway/v1/settlements/:settlementId/cancel

Only while the settlement is requested. Optional body { "reason": "…" } (up to 200 characters). Answers the cancelled settlement; the amount is credited back to your balance at once. Afterwards it fails with 409 SETTLEMENT_NOT_CANCELLABLE. Cancelling does not send a callback.

Retrieve and list settlements

GET/api/gateway/v1/settlements/:settlementId
GET/api/gateway/v1/settlements
Query parameterDescription
statusrequested, approved, verifying, completed, rejected or cancelled.
chainbsc or tron.
merchantSettlementIdYour id.
from, toCreation time range, ISO 8601.
page, limitAs for orders: page from 1, at most 100 per page (default 25).

Lists are newest first and come with pagination (page, limit, total, pages).

The settlement object

FieldMeaning
settlementIdMekaPay's id, st_ followed by 24 characters.
referenceShort reference for support, e.g. MPS261111K7QD2M.
merchantSettlementIdYour id, or null.
amount, fee, netAmountDeducted from your balance, the fixed fee, and what is sent to you (amount − fee).
address, addressLabel, settlementAddressIdThe destination as it was when you requested the settlement.
statusSee Statuses.
requestedViaapi or portal.
createdAt, approvedAt, completedAt, rejectedAt, cancelledAtISO 8601 times, null until they happen.
rejectReasonWhy MekaPay rejected it, on a rejected settlement.
txHash, explorerUrlThe verified transfer, on a completed settlement.

Errors

HTTPCodeWhen
400VALIDATION_ERRORfield is chain, amount (format, below the minimum or not above the fee), settlementAddressId (unknown, another chain, or not active: the message says pending, cooling until when, rejected or removed) or merchantSettlementId.
403SETTLEMENTS_DISABLEDSettlements are not turned on for your account.
404SETTLEMENT_NOT_FOUNDNo such settlement for your account.
409INSUFFICIENT_BALANCEThe amount is more than your balance; available says how much you can settle.
409DUPLICATE_MERCHANT_SETTLEMENT_IDThe merchantSettlementId exists with other details (settlementId names it).
409SETTLEMENT_NOT_CANCELLABLEThe settlement is no longer requested.

In the merchant portal

Balance & Settlements shows your balance per chain, lets you request a settlement (it shows the fee and the net amount before you confirm), lists your settlements, and manages your settlement addresses. Every action there (adding or removing an address, requesting or cancelling a settlement) asks for your current two-factor code, so turn on two-factor authentication first.

Payouts (代付)

Send USDT from your MekaPay balance to your users' own wallets, for example to pay out a withdrawal. You request each payout through the API, signed with your own payout key; MekaPay reviews it, sends exactly the requested amount to the address and confirms the transfer on-chain.

How it works

  1. Your server→MekaPay API

    Sign and request the payout

    Your backend calls POST /payouts with your own payout id, the chain, the user's address and the amount. Besides the usual HMAC request signature, the body carries payoutSignature, made over those fields and the time you signed (signedAt) with your payout signing key.

  2. MekaPay API

    Check and reserve the funds

    MekaPay verifies the payout signature against your active keys, checks the destination and your limits, and deducts the amount plus the fee from your balance at once. The payout is requested.

  3. MekaPay staff

    Review

    A MekaPay staff member reviews the payout and approves it with their own two-factor code (approved), or rejects it, in which case the whole amount goes back to your balance.

  4. MekaPay→Blockchain

    Send and verify

    MekaPay sends exactly amount USDT to the address and confirms the transfer from the chain itself (verifying): final, the exact amount, to the exact address.

  5. MekaPay→Your callback URL

    MekaPay calls you back

    The payout is completed and you receive a signed payout.completed callback with the transaction hash. A rejected payout sends payout.rejected.

!

Payouts are not instant. Every payout is reviewed by a MekaPay staff member before it is sent, and payouts are usually sent within working hours. Show your users the withdrawal as being processed until you receive payout.completed.

Before your first payout

  1. Payouts turned on for your account. Payouts are off for every account until MekaPay turns them on. Until then every request is refused with 403 PAYOUTS_DISABLED; GET /balance shows payoutsEnabled.
  2. An active payout signing key. Generate an Ed25519 key pair on your server and register its public key in the merchant portal. MekaPay approves it, and it can sign payouts 24 hours after approval. See Payout signing keys.
  3. A balance on the payout's chain. Payouts are paid from your balance on that chain: your confirmed deposits minus fees, settlements and earlier payouts.
  4. Your usual API access. Payout requests are signed with your API Secret and must come from a whitelisted server IP, like every other request (Authentication & signing).
i

Why a second signature. A payout sends money to an address you choose, so your API credentials alone must not be enough to make one. Every payout request also carries a signature made with a key that only your server holds: MekaPay stores only its public half and cannot sign a payout for you, and nobody can add a key to your account through the API. On top of that, a MekaPay staff member reviews every payout before it is sent.

Fees and minimum

ChainFee per payoutMinimum payout
BNB Smart Chain (BEP20)1 USDT5 USDT
TRON (TRC20)3 USDT5 USDT
  • The fee is charged on top: the user receives exactly amount, and totalAmount = amount + fee is deducted from your balance. A 250 USDT payout on TRON sends 250 USDT to the user and deducts 253 USDT.
  • totalAmount is deducted as soon as the payout is accepted, and credited back in full if MekaPay rejects the payout or you cancel it.
  • amount is a decimal string with at most 6 decimals and at least the minimum. GET /balance also returns the fee and the minimum per chain (payoutFee, payoutMinimum).

Limits

LimitDefaultWhat counts
maxPerPayout5,000 USDTThe amount of one payout.
maxPerDay20,000 USDTThe amounts of all your payouts in the last 24 hours, both chains together.
maxPerAddressPerDay5,000 USDTThe amounts of your payouts to one address on one chain in the last 24 hours.
  • The 24 hours are a rolling window, not a calendar day: a payout counts for 24 hours after it was requested. Every payout counts except rejected and cancelled ones, including the one you are requesting.
  • MekaPay can set different limits for your account. Your current limits and how much of maxPerDay you have used are in GET /balance (payoutLimits, payoutUsedLast24h).
  • A payout over a limit is refused with 409 PAYOUT_LIMIT_EXCEEDED, which names the limit and its max, and for the 24-hour limits how much was already used without this payout. Nothing is created or deducted. See Errors.

Statuses

StatusMeaning
requestedAccepted; totalAmount is deducted from your balance. Waiting for MekaPay's review. You can still cancel it.
approvedApproved by MekaPay; waiting to be sent.
verifyingThe transfer has been sent and MekaPay is confirming it on-chain.
completedFinal: the user received amount in txHash. You receive payout.completed.
rejectedFinal: MekaPay rejected it (rejectReason); totalAmount is back on your balance. You receive payout.rejected.
cancelledFinal: you cancelled it; totalAmount is back on your balance.

Lifecycle

FromToWhenCallback
(new)requestedYour POST /payouts passes every check; totalAmount is deducted.none: the API answer is your confirmation
requestedapprovedMekaPay staff approve it.none
requestedcancelledYou cancel it; totalAmount is credited back.none
requested or approvedrejectedMekaPay rejects it, which is possible only while no transfer has been recorded for it; totalAmount is credited back.payout.rejected
approvedverifyingMekaPay has sent the transfer.none
verifyingcompletedThe transfer is final and pays exactly amount USDT to address.payout.completed
verifyingapprovedThe recorded transfer turned out not to pay this payout (for example it failed on-chain). MekaPay sends it again.none

A payout is completed only after MekaPay has read the transfer from the chain itself: the transaction succeeded, it is final (BNB Smart Chain: in a finalized block; TRON: in a solidified block), and it pays exactly amount of USDT to the payout's address. A payout is never sent a second time while an earlier transfer for it could still arrive. txHash and explorerUrl are filled in once the payout is completed.

Treat completed as paid and rejected or cancelled as not paid: give the funds back to your user's account in your own system. Do not treat verifying → approved as a failure; the payout is still on its way.

When payouts are paused

MekaPay can pause payouts on one chain, for example while it investigates an incident. While a chain is paused:

  • new payout requests on that chain are refused with 503 PAYOUTS_PAUSED. Nothing is created or deducted; retry later with the same merchantPayoutId, signed again with a fresh signedAt;
  • payouts you already requested keep their status and are approved and sent once payouts resume. You can still cancel a requested one, and MekaPay can still reject one;
  • transfers that were already sent are still confirmed, and you still receive payout.completed;
  • payouts on the other chain, deposits, orders and settlements are not affected.

Endpoints

EndpointUse
POST /payoutsRequest a payout.
GET /payouts/:payoutIdRetrieve one payout.
GET /payoutsList your payouts, or find one by your merchantPayoutId.
POST /payouts/:payoutId/cancelCancel a payout that is still requested.
GET /payout-keysYour payout signing keys and their states.
GET /balanceYour balance, whether payouts are on, the fees, your limits and the last 24 hours' usage.

All of them are under https://api.mekapay.online/api/gateway/v1 and signed like every other request. Only POST /payouts needs the payout signature.

In the merchant portal

Payouts shows your balance, the fees, your limits and the last 24 hours' usage, lists your payouts with filters and the details of each one, lets you cancel a requested payout, and manages your payout signing keys. Cancelling a payout and adding or removing a key ask for your current two-factor code, so turn on two-factor authentication first. Payouts are requested only through the API, with your payout signature; the portal cannot create them.

Payout signing keys

An Ed25519 key pair that you generate on your own server. The private key signs every payout request and never leaves your server; you register only the public key, in the merchant portal. A key can sign payouts 24 hours after MekaPay approves it.

GET/api/gateway/v1/payout-keys

Generate a key pair

Generate the key on the server that will request payouts. Each version writes the private key to payout-private.pem (PKCS#8) and the public key to payout-public.pem (SPKI).

openssl genpkey -algorithm ed25519 -out payout-private.pem
openssl pkey -in payout-private.pem -pubout -out payout-public.pem
chmod 600 payout-private.pem
cat payout-public.pem   # register this in the merchant portal

genpkey overwrites an existing file without asking: never run it over a key you use.

The public key looks like this; it is safe to share:

-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEA…
-----END PUBLIC KEY-----
!

Keep the private key on your server. Store payout-private.pem like your API Secret: on the server that requests payouts, readable only by the service that uses it, or in your secret manager. Never put it in a web page, a mobile app, a repository, a ticket or a log, and never send it to anyone, MekaPay included: MekaPay never needs it and never asks for it. If it may have leaked, remove the key in the portal at once and tell MekaPay.

Register the public key

  1. Turn on two-factor authentication for the merchant portal (Security).
  2. In the portal, open Payouts → Payout keys and add the public key, an optional label (up to 60 characters) and the current code from your authenticator app.
  3. MekaPay reviews the key and approves or rejects it. Approval needs a MekaPay staff member's own two-factor code. Before approving, MekaPay may contact you through your usual channel to confirm the new key's keyId (see below for how to compute it).
  4. The key is cooling for 24 hours after approval, then active: only active keys can sign payouts.

The portal accepts the public key as the PEM file's content (-----BEGIN PUBLIC KEY-----), as the base64 of its DER form (MCowBQYDK2VwAyEA…), or as the raw 32-byte key in base64 (44 characters) or hex (64 characters). Anything else, any key that is not Ed25519, a private key, and the test key of the worked example (keyId 56475aa75463474c, whose private key is published) are refused. Never paste the private key.

i

Why keys are locked. Keys cannot be added through the API, and MekaPay staff cannot add them for you. Even with your API credentials, nobody could add a key of their own, and every new key waits 24 hours after approval, so there is time to notice and remove one you did not add.

Rules

  • At most 2 keys that are pending approval or approved (cooling or active), so that you can rotate a key without a gap. Rejected and removed keys do not count, and the same key cannot be added twice.
  • A payout signature is checked against each of your active keys; either one may sign.
  • You can remove a key at any time in the portal, with your two-factor code. MekaPay can also remove one, with a reason. Removing a key does not change payouts already requested with it.

Rotating a key

  1. Generate a new key pair and register its public key. You now have two keys.
  2. Wait until the new key is active (activatesAt, 24 hours after approval). Both keys can sign now.
  3. Switch your server to the new private key. Each payout's payoutKeyId shows which key signed it, so you can see when the old key stops being used.
  4. Remove the old key in the portal.

If a private key may have leaked, do not wait for a new one: remove the old key first. Payout requests fail with 403 PAYOUT_KEY_REQUIRED until the new key is active. Look for requested payouts whose payoutKeyId is the old key's and cancel any you did not make.

States

stateMeaning
pending_approvalAdded, waiting for MekaPay's review.
coolingApproved; can sign payouts from activatesAt, 24 hours after approval.
activeCan sign payouts.
rejectedNot approved; see rejectReason. Remove it or add another key.
removedRemoved by you or by MekaPay. It can no longer sign payouts; listed only when you ask for it.

status is the stored status (pending_approval, approved, rejected or removed); state splits approved into cooling and active. Use state.

List your keys

Query parameterDescription
statusA state from the table above, approved (cooling or active), or all. Without it, every key except removed ones.

Newest first. Keys can only be added and removed in the portal.

{
  "success": true,
  "data": [
    {
      "id": "6733a8e1c4b2d5f6a7b8c901",
      "keyId": "4ae103b263701e3f",
      "label": "Payout server, Nov 2026",
      "publicKey": "vCGHTUqY6z6H1LmClRvGIcttMyUoiEfH3KssKprKRbk=",
      "status": "approved",
      "state": "active",
      "requestedAt": "2026-11-10T02:10:44.000Z",
      "approvedAt": "2026-11-10T03:05:19.000Z",
      "activatesAt": "2026-11-11T03:05:19.000Z",
      "rejectedAt": null,
      "rejectReason": null,
      "removedAt": null,
      "removedBy": null
    },
    {
      "id": "6733f0b27d91e4a5c2b8d6f3",
      "keyId": "155a745db688c88e",
      "label": "Payout server, next key",
      "publicKey": "+yBHMdlorV5MHHjEo/JobL1Y+xAb0W+vP5KXWgYZlfU=",
      "status": "pending_approval",
      "state": "pending_approval",
      "requestedAt": "2026-11-12T08:40:02.000Z",
      "approvedAt": null,
      "activatesAt": null,
      "rejectedAt": null,
      "rejectReason": null,
      "removedAt": null,
      "removedBy": null
    }
  ]
}
FieldMeaning
idMekaPay's id for the key.
keyIdThe first 16 hex characters of the SHA-256 of the raw 32-byte public key. Compute it from your own key (below) to see which key is which. Every payout shows the key that signed it as payoutKeyId.
labelYour label, or an empty string.
publicKeyThe raw 32-byte Ed25519 public key, base64.
status, stateSee States.
requestedAt, approvedAt, activatesAtWhen you added it, when MekaPay approved it, and when it can sign payouts. ISO 8601, null until they apply.
rejectedAt, rejectReasonOn a rejected key.
removedAt, removedByOn a removed key; removedBy is merchant or staff.

The keyId of your public key file:

openssl pkey -pubin -in payout-public.pem -outform DER | tail -c 32 | openssl dgst -sha256 | sed 's/^.*= //' | cut -c1-16

Payout signature

Every POST /payouts carries payoutSignature: an Ed25519 signature over the payout's key fields and the time you signed it, made with your payout signing key. MekaPay checks it in addition to the usual HMAC request signature.

The message to sign

PAYOUT-V1
<merchantCode>
<merchantPayoutId>
<chain>
<address>
<amount>
<signedAt>
  • Seven lines joined by single line feeds (\n, byte 0x0A), never \r\n, and no newline after the last line.
  • PAYOUT-V1 is fixed: the version of this format.
  • merchantCode is your merchant code, as returned by GET /ping and sent as merchantCode in every callback, e.g. example-merchant. Not your API Client Key.
  • merchantPayoutId, chain, address and amount are exactly the strings you send in the request body: same case, same digits, nothing normalised. If you send "amount": "250.00", sign 250.00, not 250; if you send a lowercase BSC address, sign it in lowercase.
  • signedAt is the Unix time in seconds when you sign, also sent in the body, as an integer (1794460800) or a string of digits ("1794460800"). The message holds its decimal digits: 1794460800. It must be within 600 seconds of MekaPay's clock, either way, when the request arrives.
  • Encode the message as UTF-8 and sign it with Ed25519 as defined in RFC 8032 (sign the message itself, not a hash of it).
  • Put the 64-byte signature, base64-encoded, in payoutSignature. Standard base64 is recommended; URL-safe base64 and missing padding are accepted too.
  • remark is not signed.

MekaPay checks the signature against each of your active payout keys. If you have no active key, the request is refused with 403 PAYOUT_KEY_REQUIRED; if signedAt is more than 600 seconds away from MekaPay's clock, with 400 VALIDATION_ERROR (field: "signedAt", maxAgeSeconds: 600); if no key matches the signature, with 401 INVALID_PAYOUT_SIGNATURE. The key that matched is shown on the payout as payoutKeyId, and the signature is stored with the payout so that it can be checked again later.

i

Two signatures, in this order. First compute payoutSignature and put it in the body. Then serialise the body and sign the request with your API Secret as usual (the string to sign): the HMAC covers the whole body, payoutSignature included.

Why signedAt, and how to retry

A signed request that MekaPay refused (for example for a low balance, a paused chain or a limit) leaves no payout behind. Because the signature covers signedAt, such a request goes stale after 10 minutes, so nobody can send a copy of it later and make a payout you had given up on. A copy of a request that was accepted cannot make a second payout either: merchantPayoutId is unique, so it only gets the first payout back.

  • Sign every attempt afresh, with the current signedAt, and keep merchantPayoutId, chain, address and amount the same.
  • If the payout already exists (your first attempt got through, even if you never saw the answer), the retry gets it back with 200 and "created": false. Neither signedAt nor the signature is checked again, so resending the original body unchanged works too.
  • If the first attempt was refused, nothing was stored: sign again with a new signedAt and the same merchantPayoutId.
  • Keep your server's clock in sync with NTP, as for X-MP-Timestamp.

Worked example

Use these values to test your implementation. Ed25519 signatures are deterministic: the same key and message always give the same signature, so your code must produce exactly the one below. The fixed signedAt is in the past, so the API would refuse this exact request; it is for testing your signing code. The key is a published test key (its seed is the 32 bytes 00 01 02 … 1f): MekaPay refuses to register it (400 VALIDATION_ERROR, field: "publicKey"), because its private key is public.

Private key
-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f
-----END PRIVATE KEY-----
Public key
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAA6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg=
-----END PUBLIC KEY-----
keyId56475aa75463474c
merchantCodeexample-merchant
Body fields
"merchantPayoutId": "WD-20261112-0001"
"chain": "tron"
"address": "TS3AT1AuW8Gxt2W5eRiRAMtom4oHjyBSK9"
"amount": "250"
"signedAt": 1794460800
Message (98 bytes)
PAYOUT-V1
example-merchant
WD-20261112-0001
tron
TS3AT1AuW8Gxt2W5eRiRAMtom4oHjyBSK9
250
1794460800
payoutSignatureKt6R+fKaI9DuPFctvtz08GMPLc127UaIlfWkE4wipaaMhkH2UIHDFXEkwFb0MkqQUwUrz8vi83MxJmH9r9XxBw==

The programs below sign this payout with the current time as signedAt. Run with the test key in payout-private.pem, the merchant code example-merchant and signedAt fixed at 1794460800, they send exactly this payoutSignature.

Sign and send a payout

Each program signs the payout from the worked example, with the current time as signedAt, using your payout key, adds the signature to the body, signs the request with your API Secret and sends it. Replace the payout details with your own. Besides the variables in Code samples, they read MEKAPAY_MERCHANT_CODE (your merchant code) and MEKAPAY_PAYOUT_KEY_FILE (the private key file, default payout-private.pem).

// create-payout.mjs: request a MekaPay payout (Node.js 18+, no dependencies)
//   MEKAPAY_CLIENT_KEY=mpk_... MEKAPAY_API_SECRET=mps_... MEKAPAY_MERCHANT_CODE=example-merchant node create-payout.mjs
import crypto from 'node:crypto';
import fs from 'node:fs';

const API_BASE = process.env.MEKAPAY_API_BASE || 'https://api.mekapay.online/api/gateway/v1';
const CLIENT_KEY = process.env.MEKAPAY_CLIENT_KEY;
const API_SECRET = process.env.MEKAPAY_API_SECRET; // server-side only, never in a browser or app
const MERCHANT_CODE = process.env.MEKAPAY_MERCHANT_CODE; // your merchant code, as in GET /ping
// Your Ed25519 payout signing key (PKCS#8 PEM). It never leaves this server.
const PAYOUT_KEY = crypto.createPrivateKey(fs.readFileSync(process.env.MEKAPAY_PAYOUT_KEY_FILE || 'payout-private.pem'));

// payoutSignature = base64 Ed25519 signature of
//   "PAYOUT-V1\nmerchantCode\nmerchantPayoutId\nchain\naddress\namount\nsignedAt"
function payoutSignature(privateKey, merchantCode, payout) {
  const message = ['PAYOUT-V1', merchantCode, payout.merchantPayoutId, payout.chain, payout.address, payout.amount,
    String(payout.signedAt)].join('\n');
  return crypto.sign(null, Buffer.from(message, 'utf8'), privateKey).toString('base64');
}

// X-MP-Signature = hex HMAC-SHA256(apiSecret, timestamp \n METHOD \n pathWithQuery \n rawBody)
function sign(secret, timestamp, method, pathWithQuery, rawBody) {
  const stringToSign = `${timestamp}\n${method}\n${pathWithQuery}\n${rawBody}`;
  return crypto.createHmac('sha256', secret).update(stringToSign, 'utf8').digest('hex');
}

async function mekapayRequest(method, path, payload) {
  const url = new URL(API_BASE + path);
  const rawBody = payload === undefined ? '' : JSON.stringify(payload);
  const timestamp = String(Math.floor(Date.now() / 1000));
  const res = await fetch(url, {
    method,
    headers: {
      'Content-Type': 'application/json',
      'X-MP-Client-Key': CLIENT_KEY,
      'X-MP-Timestamp': timestamp,
      'X-MP-Signature': sign(API_SECRET, timestamp, method, url.pathname + url.search, rawBody),
    },
    body: rawBody === '' ? undefined : rawBody, // send exactly the bytes you signed
  });
  const json = await res.json().catch(() => null);
  if (!json || !json.success) {
    throw new Error(`MekaPay ${res.status} ${json?.code || ''}: ${json?.message || 'unexpected response'}`);
  }
  return json;
}

// The payout: your id (reuse it when you retry), the user's own wallet, and what the user receives.
const payout = {
  merchantPayoutId: 'WD-20261112-0001',
  chain: 'tron',
  address: 'TS3AT1AuW8Gxt2W5eRiRAMtom4oHjyBSK9',
  amount: '250',
  signedAt: Math.floor(Date.now() / 1000), // Unix seconds; sign each attempt afresh
  remark: 'Withdrawal for player_123',
};
payout.payoutSignature = payoutSignature(PAYOUT_KEY, MERCHANT_CODE, payout); // sign first, then send

const created = await mekapayRequest('POST', '/payouts', payout);
const p = created.data;
console.log(created.created ? 'Requested payout' : 'Payout already existed', p.payoutId, p.reference, p.status);
console.log(`${p.amount} ${p.currency} (${p.network}) to ${p.address}; ${p.totalAmount} deducted from your balance`);

PHP's sodium extension ships with PHP since 7.2; if php -m does not list it, enable it with extension=sodium in php.ini or install your distribution's sodium package. If you would rather not parse the PEM file, store the 32-byte seed instead (for example base64 in your secret manager) and pass it to sodium_crypto_sign_seed_keypair directly. In Python, cryptography is the only package needed.

Check a signature yourself

To test your signing code without calling the API, verify its output with your public key, the way MekaPay does:

import crypto from 'node:crypto';
import fs from 'node:fs';

const publicKey = crypto.createPublicKey(fs.readFileSync('payout-public.pem'));
const message = ['PAYOUT-V1', 'example-merchant', 'WD-20261112-0001', 'tron', 'TS3AT1AuW8Gxt2W5eRiRAMtom4oHjyBSK9', '250', '1794460800'].join('\n');
const signature = Buffer.from('Kt6R+fKaI9DuPFctvtz08GMPLc127UaIlfWkE4wipaaMhkH2UIHDFXEkwFb0MkqQUwUrz8vi83MxJmH9r9XxBw==', 'base64');
console.log(crypto.verify(null, Buffer.from(message, 'utf8'), publicKey, signature)); // true with the test key

Common mistakes

  • Signing other strings than you send. Build the payout once, sign its fields and send those same fields; if you send signedAt as an integer, sign its decimal digits. Do not sign a normalised amount (250) and send 250.00, or sign a checksummed address and send it in lowercase.
  • Leaving out signedAt, from the message or from the body, or signing another value than the one you send.
  • A clock that is off, or a stale signature. signedAt must be within 600 seconds of MekaPay's clock, so keep your server on NTP, and do not sign a payout once and send it much later or queue signed requests. A stale or skewed signedAt gets 400 VALIDATION_ERROR with field: "signedAt": sign again with the current time.
  • Milliseconds in signedAt (Date.now() in JavaScript), a fraction such as 1794460800.5, or a string with leading zeros. Send whole seconds: 9 to 12 digits.
  • A trailing newline after signedAt, or \r\n line endings.
  • The wrong merchant code: your API Client Key, your business name, or another account's code.
  • Hex instead of base64, or a signature of a hash of the message instead of the message itself.
  • A key that is not active yet. A key is cooling for 24 hours after approval. With no active key you get 403 PAYOUT_KEY_REQUIRED; with another key active, 401 INVALID_PAYOUT_SIGNATURE.
  • Mixing up the two 401s. INVALID_SIGNATURE is the HMAC request signature (signing mistakes); INVALID_PAYOUT_SIGNATURE is this one.
  • Signing after serialising. The payout signature goes into the body before you compute the HMAC over it.

Create a payout

Requests a payout of amount USDT to one of your users' addresses. amount plus the fee is deducted from your balance at once; MekaPay reviews the payout and sends it.

POST/api/gateway/v1/payouts

Request body

FieldRules
merchantPayoutIdstring

requiredYour id for this payout, unique within your account. 1–64 characters: letters, digits and . _ : - (^[A-Za-z0-9._:\-]{1,64}$). It makes retries safe (see below), so use one per withdrawal and reuse it when you retry.

chainstring

requiredbsc (BNB Smart Chain, BEP20) or tron (TRON, TRC20).

addressstring

requiredThe user's own wallet on that chain: a 0x… BNB Smart Chain address (lowercase or with a correct EIP-55 checksum) or a TRON T… address. Some addresses are refused: see Destination rules.

amountstring

requiredWhat the user receives: a decimal string with at most 6 decimals, at least 5 (the minimum on both chains). The fee is added on top.

signedAtinteger | string

requiredThe Unix time in seconds when you signed the payout, as an integer or a string of 9–12 digits, e.g. 1794460800. It is part of the signed message and must be within 600 seconds of MekaPay's clock.

remarkstring

optionalYour note, up to 120 characters. Shown to you and to MekaPay staff; never sent on-chain or to the user.

payoutSignaturestring

requiredThe base64 Ed25519 signature over merchantCode, merchantPayoutId, chain, address, amount and signedAt, made with one of your active payout keys. See Payout signature.

Example request

The payout from the worked example, signed there with the test key at signedAt 1794460800. Your own request carries the current time and is signed with your own key:

{
  "merchantPayoutId": "WD-20261112-0001",
  "chain": "tron",
  "address": "TS3AT1AuW8Gxt2W5eRiRAMtom4oHjyBSK9",
  "amount": "250",
  "signedAt": 1794460800,
  "remark": "Withdrawal for player_123",
  "payoutSignature": "Kt6R+fKaI9DuPFctvtz08GMPLc127UaIlfWkE4wipaaMhkH2UIHDFXEkwFb0MkqQUwUrz8vi83MxJmH9r9XxBw=="
}

Send it with the usual X-MP-Client-Key, X-MP-Timestamp and X-MP-Signature headers, the HMAC computed over this body (Authentication & signing).

Response

201 Created for a new payout, 200 OK with "created": false for an existing one.

{
  "success": true,
  "created": true,
  "data": {
    "payoutId": "pout_6Rk2Vq9Lm4Tx8Nb1Zc7Wd3Ya",
    "reference": "MPP261112R4XW7N",
    "merchantPayoutId": "WD-20261112-0001",
    "chain": "tron",
    "network": "TRC20",
    "currency": "USDT",
    "address": "TS3AT1AuW8Gxt2W5eRiRAMtom4oHjyBSK9",
    "amount": "250",
    "fee": "3",
    "totalAmount": "253",
    "remark": "Withdrawal for player_123",
    "status": "requested",
    "payoutKeyId": "4ae103b263701e3f",
    "createdAt": "2026-11-12T05:20:02.000Z",
    "approvedAt": null,
    "completedAt": null,
    "rejectedAt": null,
    "rejectReason": null,
    "cancelledAt": null,
    "txHash": null,
    "explorerUrl": null
  }
}

The fields are described in The payout object; payoutKeyId is the keyId of your key that signed the payout. address and amount come back normalised ("250.00" becomes "250", a BSC address gets its EIP-55 checksum); the signature is still checked against what you sent.

Idempotency and retries

merchantPayoutId is the idempotency key. If you send it again:

  • with the same chain, address and amount (compared after normalising, so "250.00" and "250" are the same), you get 200 with the existing payout and "created": false, whatever its status is now. signedAt and the signature are not checked again, and nothing new is created;
  • with any of them different, you get 409 DUPLICATE_MERCHANT_PAYOUT_ID, whose payoutId names the existing payout, and nothing changes;
  • while the first request with that id is still being processed, you get 503 TRY_AGAIN: send it again a moment later.

So after a timeout or a network error, send the request again with the same merchantPayoutId, chain, address and amount: you never pay a user twice. Sign each attempt afresh, with a new signedAt, a new payout signature and a new HMAC timestamp: if the first attempt got through you get it back, and if it was refused (nothing was stored), the retry is checked like a new request and needs a fresh signedAt anyway. Find a payout by your id with GET /payouts?merchantPayoutId=…. Use a new merchantPayoutId for each new withdrawal.

Order of checks

After the request signature and the IP whitelist, MekaPay checks a payout request in this order and answers with the first problem it finds. A refused request creates nothing and deducts nothing.

#CheckIf it fails
1Every field is present and well-formed (Request body).400 VALIDATION_ERROR with field: merchantPayoutId, chain, address, amount (format, or below the minimum: then with minimum), remark, signedAt (missing, or not 9–12 digits) or payoutSignature (missing, or not a base64 64-byte signature)
2A payout with this merchantPayoutId already exists.Same chain, address and amount: 200 with that payout and "created": false (503 TRY_AGAIN while the first request is still being processed). Otherwise 409 DUPLICATE_MERCHANT_PAYOUT_ID
3Payouts are turned on for your account, and your account is active.403 PAYOUTS_DISABLED; 403 PROJECT_SUSPENDED
4You have an active payout key; signedAt is within 600 seconds of MekaPay's clock; and payoutSignature matches one of your active keys.403 PAYOUT_KEY_REQUIRED; 400 VALIDATION_ERROR with field: "signedAt" and maxAgeSeconds: 600; 401 INVALID_PAYOUT_SIGNATURE
5Payouts on the chain are not paused.503 PAYOUTS_PAUSED
6The address may receive payouts (Destination rules).400 DESTINATION_NOT_ALLOWED with field: "address" and reason; 503 DESTINATION_CHECK_UNAVAILABLE
7amount is at most your maxPerPayout.409 PAYOUT_LIMIT_EXCEEDED with limit: "maxPerPayout" and max
8With this payout, your payouts of the last 24 hours stay within maxPerDay (both chains), and those to this address on this chain within maxPerAddressPerDay.409 PAYOUT_LIMIT_EXCEEDED with limit, max and used (without this payout)
9Your balance on the chain covers amount + fee.409 INSUFFICIENT_BALANCE with available

Because the retry check (2) comes before the others, a retry of a payout that was already accepted always gets it back, even if payouts have been paused, limits reached or its signedAt gone stale since. The limits are checked so that two requests sent at the same moment can never pass a limit together.

Destination rules

Pay to the user's own wallet. These addresses are refused with 400 DESTINATION_NOT_ALLOWED; reason says why:

reasonThe address is
not_a_walletThe zero address or the USDT token contract.
own_deposit_walletA MekaPay deposit address, of your account or any other, on either chain (a BSC and a TRON address with the same 20-byte body count as the same). Never pay out to the address a user deposits to: pay to the wallet the user withdraws to.
own_sending_walletOne of the wallets MekaPay sends payouts and settlements from.
blacklistedTRON only: blacklisted on the USDT contract by its issuer (Tether's isBlackListed). USDT sent there could never be moved again.

If MekaPay cannot read the TRON blacklist at that moment, the request is refused with 503 DESTINATION_CHECK_UNAVAILABLE; nothing is created, so send the same payout again a little later (signed again with a fresh signedAt if the first one is more than a few minutes old). A TRON address is checked against the blacklist again when the payout is approved: if it has been blacklisted in the meantime, MekaPay rejects the payout and totalAmount goes back to your balance. Treat any reason you do not recognise as refused; new reasons may be added.

Errors

HTTPCodeWhen
400VALIDATION_ERRORA field is missing or malformed, amount is below the minimum (with minimum), or signedAt is more than 600 seconds from MekaPay's clock (with maxAgeSeconds). field names the field.
400DESTINATION_NOT_ALLOWEDThe address may not receive payouts: field is address, reason says why (Destination rules).
401INVALID_PAYOUT_SIGNATUREpayoutSignature does not match any of your active payout keys (common mistakes).
403PAYOUTS_DISABLEDPayouts are not turned on for your account.
403PROJECT_SUSPENDEDYour merchant account is not active.
403PAYOUT_KEY_REQUIREDYou have no active payout key: none added yet, still pending approval, or still cooling.
409DUPLICATE_MERCHANT_PAYOUT_IDThe merchantPayoutId exists with another chain, address or amount (payoutId names it).
409PAYOUT_LIMIT_EXCEEDEDThe payout would go over a limit: limit (maxPerPayout, maxPerDay or maxPerAddressPerDay), max, and for the 24-hour limits used.
409INSUFFICIENT_BALANCEamount + fee is more than your balance on the chain; available is that balance.
503PAYOUTS_PAUSEDMekaPay has paused payouts on this chain. Retry later.
503DESTINATION_CHECK_UNAVAILABLEThe TRON blacklist could not be read. Retry the same payout shortly (with a fresh signedAt once the first is stale).
503TRY_AGAINA request with this merchantPayoutId is still being processed, or the request could not be completed. Nothing was created twice; send the same request again a moment later.
{
  "success": false,
  "code": "VALIDATION_ERROR",
  "message": "signedAt must be within 600 seconds of the current time: sign the payout again with the current Unix time.",
  "field": "signedAt",
  "maxAgeSeconds": 600
}
{
  "success": false,
  "code": "PAYOUT_LIMIT_EXCEEDED",
  "message": "This payout is over your limit: at most 20000 USDT of payouts per 24 hours (19850 USDT already used).",
  "limit": "maxPerDay",
  "max": "20000",
  "used": "19850"
}
{
  "success": false,
  "code": "DESTINATION_NOT_ALLOWED",
  "message": "address is on the USDT contract's blacklist: USDT sent there would be frozen.",
  "field": "address",
  "reason": "blacklisted"
}

With a stale signedAt, sign the payout again with the current time and send it with the same merchantPayoutId. With PAYOUT_LIMIT_EXCEEDED on a 24-hour limit, you can pay out at most max − used now, or the rest once earlier payouts are more than 24 hours old. With INSUFFICIENT_BALANCE, the largest payout you can request is available − fee. The authentication errors on Authentication & signing apply as well.

Retrieve / cancel payouts

Read one payout, page through your payouts or find one by your own id, and cancel a payout that MekaPay has not approved yet.

Retrieve a payout

GET/api/gateway/v1/payouts/:payoutId

Returns the payout object. Answers 404 PAYOUT_NOT_FOUND when the payout does not exist or belongs to another merchant.

List payouts

GET/api/gateway/v1/payouts
Query parameterDescription
statusrequested, approved, verifying, completed, rejected or cancelled.
chainbsc or tron.
merchantPayoutIdYour id: finds the payout you sent with it.
from, toCreation time range, ISO 8601.
page, limitAs for orders: page from 1, at most 100 per page (default 25).

Lists are newest first and come with pagination. Sign the query string exactly as you send it, for example /api/gateway/v1/payouts?status=completed&chain=tron.

{
  "success": true,
  "data": [
    {
      "payoutId": "pout_6Rk2Vq9Lm4Tx8Nb1Zc7Wd3Ya",
      "reference": "MPP261112R4XW7N",
      "merchantPayoutId": "WD-20261112-0001",
      "chain": "tron",
      "network": "TRC20",
      "currency": "USDT",
      "address": "TS3AT1AuW8Gxt2W5eRiRAMtom4oHjyBSK9",
      "amount": "250",
      "fee": "3",
      "totalAmount": "253",
      "remark": "Withdrawal for player_123",
      "status": "approved",
      "payoutKeyId": "4ae103b263701e3f",
      "createdAt": "2026-11-12T05:20:02.000Z",
      "approvedAt": "2026-11-12T07:41:16.000Z",
      "completedAt": null,
      "rejectedAt": null,
      "rejectReason": null,
      "cancelledAt": null,
      "txHash": null,
      "explorerUrl": null
    }
  ],
  "pagination": { "page": 1, "limit": 25, "total": 1, "pages": 1 }
}

Callbacks tell you when a payout is completed or rejected. If you also poll, poll a single payout no more often than once a minute and stop when it reaches a final status: payouts wait for a MekaPay review and are usually sent within working hours.

Cancel a payout

POST/api/gateway/v1/payouts/:payoutId/cancel
  • Only while the payout is requested, that is before MekaPay approves it. Afterwards the request fails with 409 PAYOUT_NOT_CANCELLABLE.
  • Optional body { "reason": "…" } (up to 200 characters). Without a body, sign the request with an empty rawBody, as for cancelling an order.
  • Answers the cancelled payout; totalAmount is credited back to your balance at once.
  • Cancelling does not send a callback; the response is your confirmation.
{
  "success": true,
  "data": {
    "payoutId": "pout_6Rk2Vq9Lm4Tx8Nb1Zc7Wd3Ya",
    "merchantPayoutId": "WD-20261112-0001",
    "status": "cancelled",
    "totalAmount": "253",
    "cancelledAt": "2026-11-12T05:31:52.000Z"
  }
}
HTTPCodeWhen
404PAYOUT_NOT_FOUNDNo such payout for your account.
409PAYOUT_NOT_CANCELLABLEThe payout is no longer requested: MekaPay approved it, or it is final.

The payout object

The same object is returned by every payout endpoint and sent in payout callbacks.

FieldMeaning
payoutIdMekaPay's id, pout_ followed by 24 letters and digits.
referenceShort reference for support: MPP + date (yyMMdd, UTC) + 6 characters, e.g. MPP261112R4XW7N.
merchantPayoutIdYour id, as sent.
chain, network, currencybsc / BEP20 or tron / TRC20; always USDT.
addressThe destination, normalised: EIP-55 checksummed 0x… on BSC, Base58 T… on TRON.
amountWhat the user receives, as a normalised decimal string.
feeThe fixed fee on this chain when you requested the payout, charged on top.
totalAmountamount + fee: what was deducted from your balance.
remarkYour note, or an empty string.
statusSee Statuses.
payoutKeyIdThe keyId of your payout key that signed the payout.
createdAt, approvedAt, completedAt, rejectedAt, cancelledAtISO 8601 times in UTC, null until they happen.
rejectReasonWhy MekaPay rejected it, on a rejected payout.
txHash, explorerUrlThe verified transfer and its link on BscScan or Tronscan, on a completed payout; null before.

Payment page

Every order has a hosted payment page at its paymentUrl. Send the user there and MekaPay guides them through the transfer.

https://mekapay.online/pay/<orderId>

Redirect the user to it, open it in a new tab, or load it in your app's in-app browser. The page works on phones and desktops, and needs no login. Use the link exactly as returned in paymentUrl.

What the user sees

  • Your business name ("Pay Example Merchant") and the MekaPay brand.
  • The amount to pay (fixed mode, with a copy button) or "Any amount · minimum X USDT" (any mode).
  • The network, for example USDT · BNB Smart Chain (BEP20), with a clear warning to send only USDT on that network.
  • A QR code of the deposit address and the address with a copy button.
  • A live countdown to expiresAt.
  • A progress tracker: Waiting for payment → Confirming n/N → Paid, with the detected transaction and a link to the block explorer.
  • The order reference (MP…) and help text: send the exact amount, network fees are paid by the sender, exchanges that deduct fees from the amount can make a payment fall short.

The page is available in English and Simplified Chinese. It follows the browser's language and has a language switch. It refreshes the order every few seconds while it is waiting.

Page states

Order statusThe page shows
pendingPayment instructions, QR code and countdown. In fixed mode after a partial payment, the remaining amount.
confirmingThe detected transfer and its confirmation count.
paidA success screen with the amount received, a note if the order was overpaid, and "Return to merchant" when you set returnUrl.
expiredThe order has expired. If part of the amount arrived, the user is asked to contact you with the order reference.
cancelledThe order was cancelled and must not be paid.
unknown id"Payment not found".

Returning to your site

When the order is paid, expired or cancelled, the page offers a "Return to merchant" button linking to your returnUrl. The return itself proves nothing: users can open that URL directly. Credit only on the signed payment.paid callback, or after reading the order with your API credentials.

Showing the payment in your own interface

You can skip the hosted page and show address, amount (or minAmount), network and expiresAt yourself. If you do:

  • name the network and token plainly ("USDT on BNB Smart Chain (BEP20)" or "USDT on TRON (TRC20)") and warn that other tokens and networks are not credited;
  • encode only the plain address in a QR code, not a payment URI, because wallets interpret URIs differently;
  • show a countdown to expiresAt and the reference;
  • update the status from callbacks or by reading the order, never from your own chain watching.

Privacy and security

  • The link is public but unguessable: the orderId is 24 random characters. Anyone with the link sees the amount, the address, the status and your business name.
  • The page never shows your userId, merchantOrderId or metadata.
  • The page is not indexed by search engines and loads no third-party scripts or trackers.

Callbacks

MekaPay sends a signed HTTPS POST to your callback URL when an order is paid, when it expires, when money arrives that does not pay an order, when a settlement is completed or rejected, and when a payout is completed or rejected.

Your callback URL

  • Set it in the merchant portal under Integration, or ask MekaPay to set it for you.
  • It must be an https:// URL with a valid certificate, reachable from the internet. Addresses that resolve to private, loopback or link-local networks are refused.
  • Redirects are not followed: a 3xx answer counts as a failure.
  • Events that happen while no URL is set are recorded as failed deliveries (NO_CALLBACK_URL). Resend them from the portal once the URL is in place.

Events

EventSent whendata
payment.paidAn order becomes paid, including a late transfer that completes an expired or cancelled order, and a manual approval by MekaPay staff.order and the deposit that completed it (null for a manual approval)
payment.expiredAn order becomes expired: at the earliest 3 minutes after expiresAt, a little later if MekaPay's chain scanner is catching up.order; deposit is null
deposit.unmatchedA confirmed deposit could not pay an order: no order covered it, it was below minAmount, it did not complete an expired or cancelled order, or the order was already paid (order_already_paid).deposit (with userId and unmatchedReason), and the related order if there is one, else null
settlement.completedA settlement was verified on-chain: netAmount arrived at your address.settlement (the settlement object, with txHash)
settlement.rejectedMekaPay rejected a settlement; the amount is back on your balance.settlement (with rejectReason)
payout.completedA payout was verified on-chain: the user received amount at address.payout (the payout object, with txHash)
payout.rejectedMekaPay rejected a payout; totalAmount is back on your balance.payout (with rejectReason)

Payout callbacks are signed, retried and resent exactly like the others. Mark a withdrawal as paid only on payout.completed (or after reading the payout with status completed), deduplicate by eventId as for every event, and treat payout.rejected as not paid. Cancelling a payout yourself sends no callback.

Ignore events you do not know. MekaPay may add new event types. Answer 2xx to an event your code does not handle and do nothing else; do not fail on an unknown event name or on extra fields in data.

Order creation and cancellation do not send callbacks; your API call already has the answer. Every confirmed deposit is reported: in order.deposits of a payment.paid or payment.expired, or as the deposit of its own deposit.unmatched.

Crediting rule

amountReceived is the order's running total of confirmed, matched transfers. Keep, per orderId, the amount you have already credited, and on every payment.paid (and on a payment.expired you decide to honour) credit amountReceived minus that amount. This way a partial amount you credited on payment.expired is never credited again when a late payment.paid arrives for the same order, and a repeated callback credits nothing. Credit each deposit.unmatched by its depositId, at most once. The rule is the same for an order MekaPay staff approved by hand: its amountReceived is the approved total.

The request

HeaderValue
Content-Typeapplication/json
User-AgentMekaPay-Gateway/1.0
X-MP-EventThe event name, e.g. payment.paid.
X-MP-Event-IdThe event id (evt_…), the same as eventId in the body.
X-MP-Delivery-Attempt1 for the first attempt, then 2, 3, …
X-MP-TimestampUnix time in seconds when this attempt was signed.
X-MP-SignatureLowercase hex HMAC_SHA256(apiSecret, timestamp + "." + rawBody).

Payloads

Every callback has the same envelope. order is the order object, deposit the deposit object, settlement the settlement object and payout the payout object returned by the API.

{
  "eventId": "evt_3NpXa9Lm2Qr7Tz5Vb8Kd1Wy4",
  "event": "payment.paid",
  "createdAt": "2026-11-11T10:05:12.000Z",
  "merchantCode": "example-merchant",
  "data": {
    "order": {
      "orderId": "po_8Kd2Qx7mN4pR1sT6vW9yZ3bC",
      "reference": "MP2611117K2QDM",
      "merchantOrderId": "INV-10023",
      "userId": "player_123",
      "mode": "fixed",
      "status": "paid",
      "chain": "bsc",
      "network": "BEP20",
      "currency": "USDT",
      "tokenContract": "0x55d398326f99059fF775485246999027B3197955",
      "decimals": 18,
      "amount": "100.5",
      "minAmount": null,
      "amountReceived": "100.5",
      "overpaid": false,
      "underpaid": false,
      "address": "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed",
      "addressType": "single_use",
      "paymentUrl": "https://mekapay.online/pay/po_8Kd2Qx7mN4pR1sT6vW9yZ3bC",
      "requiredConfirmations": 15,
      "expiresAt": "2026-11-11T10:30:00.000Z",
      "createdAt": "2026-11-11T10:00:00.000Z",
      "paidAt": "2026-11-11T10:05:02.000Z",
      "expiredAt": null,
      "cancelledAt": null,
      "cancelReason": null,
      "returnUrl": "https://merchant.example/deposit/done",
      "metadata": { "cartId": "C-778" },
      "deposits": [ { "depositId": "6650a1f2c3d4e5f6a7b8c9d0", "…": "same as data.deposit" } ]
    },
    "deposit": {
      "depositId": "6650a1f2c3d4e5f6a7b8c9d0",
      "chain": "bsc",
      "txHash": "0xf37603c2d85a42bb4f4f9f1f152732627539f28e7d2e6dc486db3be949dda686",
      "logPos": 0,
      "from": "0xfB6916095ca1df60bB79Ce92cE3Ea74c37c5d359",
      "to": "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed",
      "amount": "100.5",
      "status": "confirmed",
      "confirmations": 15,
      "requiredConfirmations": 15,
      "blockNumber": 39000000,
      "blockTimestamp": "2026-11-11T10:04:41.000Z",
      "detectedAt": "2026-11-11T10:04:44.000Z",
      "confirmedAt": "2026-11-11T10:04:58.000Z",
      "matchStatus": "matched",
      "unmatchedReason": null,
      "orderId": "po_8Kd2Qx7mN4pR1sT6vW9yZ3bC",
      "explorerUrl": "https://bscscan.com/tx/0xf37603c2d85a42bb4f4f9f1f152732627539f28e7d2e6dc486db3be949dda686"
    }
  }
}

Verifying the signature

Verify every callback before you act on it:

  1. Read the raw request body as bytes, before any JSON parsing. Re-serialised JSON does not produce the same bytes.
  2. Check that X-MP-Timestamp is an integer within 300 seconds of your clock. Each attempt is signed when it is sent, so retries carry fresh timestamps.
  3. Compute HMAC_SHA256(apiSecret, timestamp + "." + rawBody) as lowercase hex, with your whole API Secret string as the key.
  4. Compare it with X-MP-Signature using a constant-time comparison (crypto.timingSafeEqual, hash_equals, hmac.compare_digest).
  5. Reject the request with 401 if any check fails.
import crypto from 'node:crypto';

function isValidMekapayCallback(apiSecret, rawBody, timestamp, signature) {
  if (!/^\d{1,12}$/.test(timestamp || '') || !/^[0-9a-f]{64}$/i.test(signature || '')) return false;
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  const expected = crypto.createHmac('sha256', apiSecret).update(`${timestamp}.${rawBody}`, 'utf8').digest();
  return crypto.timingSafeEqual(expected, Buffer.from(signature, 'hex'));
}

Complete receivers in Node.js, PHP and Python are in Code samples.

Worked example

With the test secret from Authentication, this (shortened) body and timestamp must give the signature below.

API Secretmps_AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8
X-MP-Timestamp1794391512
rawBody
{"eventId":"evt_3NpXa9Lm2Qr7Tz5Vb8Kd1Wy4","event":"payment.paid","createdAt":"2026-11-11T10:05:12.000Z","merchantCode":"example-merchant","data":{"order":{"orderId":"po_8Kd2Qx7mN4pR1sT6vW9yZ3bC","status":"paid"},"deposit":null}}
Signed payload
1794391512.{"eventId":"evt_3NpXa9Lm2Qr7Tz5Vb8Kd1Wy4","event":"payment.paid", … }
X-MP-Signaturec10c65cc2f03d52438d090c3823914dcc18df2f2886f3af11368376d27ad775f

Responding and retries

Answer with any 2xx status within 10 seconds. Store the event and do slow work afterwards. Anything else (a timeout, a connection error, a 3xx, 4xx or 5xx) is a failed attempt, and MekaPay retries on this schedule:

AttemptSent
1As soon as the event happens (within a few seconds)
260 seconds after attempt 1 failed
35 minutes later
415 minutes later
51 hour later
63 hours later
76 hours later
812 hours later
924 hours later; if this fails too, the delivery is marked failed

Retries cover about 46 hours. A failed delivery can be resent at any time from the portal (Integration → Recent callbacks) or by MekaPay support. A resend carries the same eventId and payload. A resend is refused with 409 RESEND_PENDING while a delivery of the same event is still queued or retrying, and with 429 RESEND_LIMIT while 50 resent callbacks of your account are still waiting to go out.

Slow endpoints are deferred. MekaPay sends at most 2 callbacks to one merchant at a time. After 3 timeouts or connection errors in a row, your pending callbacks are held back for 2 minutes, then 4, 8 and so on up to 30 minutes, until your endpoint answers 2xx again. Held-back time does not use up retry attempts, but events reach you later. Answer quickly and do slow work after responding.

Idempotency and ordering

  • Deduplicate on eventId. The same event can reach you more than once (a retry after your response was lost, or a manual resend). Store processed event ids with a unique constraint and answer 200 to repeats without acting again.
  • Credit at most once per orderId on payment.paid. Make the credit and the record of it one database transaction.
  • Do not rely on order of arrival. In particular, payment.paid can arrive after payment.expired for the same order when a transfer sent in time confirms late, and for an order you cancelled when the user had already paid. Credit it by the Crediting rule.
  • Use the payload, or re-read the order. The payload is the order as it was when the event happened. For extra assurance, read it again with GET /orders/:orderId before crediting.

Errors

Errors use standard HTTP status codes and a stable machine-readable code.

Error envelope

{
  "success": false,
  "code": "AMOUNT_BELOW_MINIMUM",
  "message": "amount must be at least 5 USDT on tron"
}

Some errors add fields: VALIDATION_ERROR includes field, the request field at fault (and maxAgeSeconds for a payout's stale signedAt); AMOUNT_BELOW_MINIMUM includes field and minimum; ACTIVE_ORDER_IN_PROGRESS includes orderId, the order that blocks the request; IP_WHITELIST_REQUIRED and IP_NOT_WHITELISTED include ip, the address MekaPay received the request from; INSUFFICIENT_BALANCE includes available, the balance you can settle or pay out; DESTINATION_NOT_ALLOWED includes field and reason; PAYOUT_LIMIT_EXCEEDED includes limit, max and, for the 24-hour limits, used. Write your code against code; message is meant for logs and people and may change.

{
  "success": false,
  "code": "IP_NOT_WHITELISTED",
  "message": "Requests from 203.0.113.7 are not allowed for this merchant.",
  "ip": "203.0.113.7"
}

Error codes

HTTPCodeMeaningWhat to do
400VALIDATION_ERRORA field is missing or malformed (see field).Fix the request.
400INVALID_AMOUNTNot a positive decimal string with at most 6 decimals, or above 1,000,000.Send e.g. "100.50".
400AMOUNT_BELOW_MINIMUMamount below your account minimum, or minAmount below the platform floor.Raise it to at least minimum from the response.
401MISSING_AUTH_HEADERSAn X-MP-* header is missing.Send all three headers.
401INVALID_CLIENT_KEYUnknown, revoked or rotated client key.Check the key; ask MekaPay if it was rotated.
401TIMESTAMP_OUT_OF_RANGETimestamp malformed or more than 300 s off.Use Unix seconds; sync your clock.
401INVALID_SIGNATUREThe signature does not match.See signing mistakes.
415UNSUPPORTED_MEDIA_TYPEA request body was not sent as JSON.Send it with Content-Type: application/json.
403GATEWAY_DISABLEDThe gateway is not enabled for your account.Contact MekaPay.
403PROJECT_SUSPENDEDYour merchant account is not active.Contact MekaPay.
403IP_WHITELIST_REQUIREDNo server IP is whitelisted for your account yet (see ip).Send ip to MekaPay support to have it whitelisted.
403IP_NOT_WHITELISTEDThe request came from an IP that is not on your whitelist (see ip).Call from a whitelisted server, or ask MekaPay support to add ip. See IP whitelist.
404ORDER_NOT_FOUNDNo such order for your account.Check the id.
409DUPLICATE_MERCHANT_ORDER_IDThe merchantOrderId exists with other details.Use a new id for a new payment.
409ACTIVE_ORDER_IN_PROGRESSThe user's active order on this chain has a transfer on its way.Wait for orderId to finish.
409ORDER_NOT_CANCELLABLEThe order is final or has a detected transfer.Let it finish.
403SETTLEMENTS_DISABLEDSettlements are not turned on for your account.Contact MekaPay.
404SETTLEMENT_NOT_FOUNDNo such settlement for your account.Check the id.
409INSUFFICIENT_BALANCEA settlement, or a payout's amount plus fee, is larger than your balance (see available).Request at most available (for a payout, available minus the fee).
409DUPLICATE_MERCHANT_SETTLEMENT_IDThe merchantSettlementId exists with other details.Use a new id for a new settlement.
409SETTLEMENT_NOT_CANCELLABLEThe settlement is no longer requested.Nothing to do; it is being processed.
400DESTINATION_NOT_ALLOWEDThe payout address may not receive payouts (see reason).Ask the user for their own wallet address. See Destination rules.
401INVALID_PAYOUT_SIGNATUREpayoutSignature matches none of your active payout keys.See payout signature mistakes.
403PAYOUTS_DISABLEDPayouts are not turned on for your account.Contact MekaPay.
403PAYOUT_KEY_REQUIREDYou have no active payout key (none added, pending approval or still cooling).Add a key in the portal, or wait for its activatesAt.
404PAYOUT_NOT_FOUNDNo such payout for your account.Check the id.
409DUPLICATE_MERCHANT_PAYOUT_IDThe merchantPayoutId exists with another chain, address or amount.Use a new id for a new payout.
409PAYOUT_LIMIT_EXCEEDEDThe payout would go over limit (see max and used).Pay out less, wait for the 24-hour window to move on, or ask MekaPay about your limits.
409PAYOUT_NOT_CANCELLABLEThe payout is no longer requested.Nothing to do; it is being processed.
503PAYOUTS_PAUSEDMekaPay has paused payouts on this chain.Retry later with the same merchantPayoutId and a fresh signedAt.
503DESTINATION_CHECK_UNAVAILABLEThe TRON blacklist could not be read.Retry the same payout shortly, signed again if needed.
503TRY_AGAINA payout request with the same merchantPayoutId is still being processed.Retry the same request a moment later.
429RATE_LIMITEDMore than 300 requests per minute.Back off and retry.
503POOL_EXHAUSTEDNo free deposit address for a new user on this chain.Retry later.

Retrying safely

  • 4xx errors other than 429 will fail again unchanged. Fix the request first. A 403 IP_WHITELIST_REQUIRED or IP_NOT_WHITELISTED keeps failing until the whitelist or your server's address changes: alert someone with the ip from the response instead of retrying.
  • 429, 503, other 5xx, timeouts and network errors are temporary. Retry with exponential back-off (for example 1 s, 2 s, 4 s, up to a minute).
  • Retrying POST /orders with the same merchantOrderId and details is always safe: you get the original order back.
  • Retrying POST /payouts with the same merchantPayoutId, chain, address and amount is always safe too: you get the original payout back, or, if the first attempt was refused, the request is checked again as a new one. Sign each attempt again with a fresh signedAt: a payout signature is only accepted for 600 seconds.
  • A retried request needs a new timestamp and therefore a new signature.

Chains & confirmations

MekaPay accepts USDT on two networks. A transfer counts only once it is final on its chain.

Supported networks

BNB Smart ChainTRON
chainbsctron
Token standardBEP20TRC20
USDT contract0x55d398326f99059fF775485246999027B3197955TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t
On-chain decimals186
Address format0x + 40 hex, EIP-55 checksummedBase58Check, starts with T
Final whenThe block is at or below the chain's finalized block and has at least 15 confirmationsThe block is solidified (confirmed by 19 of 27 super representatives) and the transaction receipt is SUCCESS
requiredConfirmations1519
Typical time to finalAbout 10 secondsAbout 1 minute
Minimum deposit (platform floor)1 USDT5 USDT
Explorerbscscan.comtronscan.org

The TRON minimum is higher because moving funds out of a TRON address costs several TRX in network fees. Your account can have a higher minimum on either chain. That account minimum applies to fixed amounts and is the default minAmount of any-amount orders; a minAmount you send explicitly only has to clear the platform floor.

Detection and confirmation

MekaPay watches the USDT contract on both chains for transfers into assigned deposit addresses. On BSC a transfer is usually detected within a few seconds and becomes final about 10 seconds later. On TRON, MekaPay reads only confirmed events, so a transfer typically appears after about a minute and is final almost at once. While a transfer is detected but not yet final, the order is confirming and the payment page shows Confirming n/N; MekaPay re-checks the transfer against the chain before it counts. If a transfer drops out of the chain or its transaction failed, the deposit becomes orphaned or failed and never counts.

Amounts

  • Send amounts as decimal strings with at most 6 decimals, for example "100.50". Do not use floating-point numbers for money.
  • MekaPay returns normalised strings without trailing zeros: "100.50" becomes "100.5", "1.000000" becomes "1".
  • USDT on BSC has 18 on-chain decimals, so a received amount (amountReceived, deposit.amount) can have up to 18 decimals on BSC. Parse amounts with a decimal library or as integers of the smallest unit.
  • Network fees are paid by the sender, in BNB on BSC and in TRX (or energy) on TRON, and do not reduce the USDT amount. Exchange withdrawal fees often do: see the warning in Payment modes.

Wrong network or token

!

Only USDT on the order's network is detected. USDT sent on another network, other tokens (BNB, TRX, USDC and so on) and NFTs sent to a deposit address are not detected and not credited automatically. Contact MekaPay support with the transaction hash if this happens; recovery is manual and not guaranteed.

A BSC address is also a valid address on Ethereum and other EVM networks. Make sure users select BNB Smart Chain (BEP20) in their wallet or exchange, not Ethereum (ERC20).

Testing & go-live checklist

Test every path your users can take with small real payments, then check the list below before you switch real users over.

How to test

Version 1 of the gateway has no separate sandbox: tests run against the live API with real USDT. Small BSC orders are the cheapest way to test (the minimum is 1 USDT and BEP20 network fees are low). Your agreed fee applies to test deposits as well, so keep test amounts small. Use test user ids such as test-user-1 so you can recognise test orders in the portal.

Run API tests from a server whose IP is on your whitelist. Requests from a developer's computer, a CI runner or a staging server are refused with 403 IP_NOT_WHITELISTED unless MekaPay support has whitelisted that address too.

Before sending money, use the worked examples to unit-test your signing code, and point the sample receivers at your own verification logic.

Test plan

#ScenarioExpected result
1GET /ping from each whitelisted serverYour merchant code, gatewayEnabled: true.
2GET /ping, correctly signed, from a computer that is not whitelisted403 IP_NOT_WHITELISTED with that computer's address in ip; your code reports it and does not retry.
3Fixed 1 USDT order on BSC, pay exactly 1Page shows Paid; payment.paid with amountReceived: "1"; credited once.
4Same POST /orders sent twiceSecond answer 200 with "created": false and the same orderId.
5Fixed 2 USDT order, pay 1 and then 1Page shows the remaining 1 after the first transfer; paid after the second.
6Fixed 1 USDT order, pay 1.2Paid with overpaid: true, amountReceived: "1.2".
7Any-amount order on BSC, pay 3Paid with amountReceived: "3".
8Any-amount order on BSC with minAmount: "2", pay 1deposit.unmatched with below_minimum; the order keeps waiting.
9Order with expiresInMinutes: 15, do not payAbout 18 minutes later (a little more if MekaPay's scanner is catching up): payment.expired, page shows Expired.
10Create an order and cancel itstatus: "cancelled", cancelReason: "merchant"; no callback.
11Two orders in a row for the same user and chainThe first is cancelled (superseded); same address on both.
12Callback with a changed body or wrong signatureYour endpoint answers 401 and does nothing.
13Resend a delivered callback from the portalYour endpoint answers 200 and does not credit again.
14GET /depositsEvery test transfer is listed with its match status.
15Ask MekaPay support to approve an unpaid test order by handpayment.paid with deposit: null, amountReceived = the approved total and a manualApproval object; credited once by the Crediting rule.

Go-live checklist

  • The API Secret is stored only in your server-side secret store: not in front-end code, mobile apps, repositories, tickets or logs.
  • Every server that calls the API (production, failover, background workers) has a static public IP that MekaPay support has whitelisted, and GET /ping succeeds from each one, over IPv6 too if the server has IPv6.
  • Server moves, new NAT gateways and other IP changes are agreed with MekaPay support before traffic switches over.
  • Your servers keep time with NTP and send X-MP-Timestamp in seconds.
  • You sign the exact body bytes you send, and the path with its query string.
  • Every payment attempt has its own merchantOrderId, and retries reuse it.
  • The callback URL is HTTPS with a valid certificate, public, without redirects, and answers 2xx within 10 seconds.
  • Callbacks are verified over the raw body, with a timestamp check and a constant-time comparison.
  • eventId is stored with a unique constraint; each order is credited at most once.
  • payment.paid after payment.expired, or for an order you cancelled, is credited.
  • deposit.unmatched events reach a person who can resolve them.
  • If you show payment details yourself, the network and token are named plainly with a wrong-network warning.
  • 429, 503 and network errors are retried with back-off.
  • 403 IP_WHITELIST_REQUIRED and IP_NOT_WHITELISTED are not retried; they alert someone, with the ip from the response.
  • Someone checks failed callbacks in the portal and reconciles deposits daily.

Support

Contact your MekaPay account manager. For a payment question include the order reference (MP…) or orderId, and the transaction hash if the user has one. Never send your API Secret to anyone, MekaPay staff included.

Code samples

Complete, dependency-free programs in Node.js, PHP and Python: create an order with a signed request, and receive and verify callbacks.

Requirements and configuration

LanguageVersionUses
Node.js18 or laterBuilt-in crypto, fetch and http
PHP7.4 or laterhash_hmac, hash_equals and the curl extension
Python3.8 or laterStandard library only (hmac, urllib, http.server)
Environment variableMeaning
MEKAPAY_CLIENT_KEYYour API Client Key (mpk_…).
MEKAPAY_API_SECRETYour API Secret (mps_…).
MEKAPAY_API_BASEOptional. Defaults to https://api.mekapay.online/api/gateway/v1.
PORT, HOSTOptional, for the callback receivers. Default port 3000 on all interfaces.

Run the order programs on a server whose IP is on your whitelist; from anywhere else they get 403 IP_NOT_WHITELISTED. The whitelist does not apply to callbacks, so it does not affect the receivers.

Create an order

Each program creates the fixed-amount order from the worked example, prints the address and the payment link, and reads the order back by merchantOrderId. Replace the order details with your own.

// create-order.mjs: create a MekaPay payment order (Node.js 18+, no dependencies)
//   MEKAPAY_CLIENT_KEY=mpk_... MEKAPAY_API_SECRET=mps_... node create-order.mjs
import crypto from 'node:crypto';

const API_BASE = process.env.MEKAPAY_API_BASE || 'https://api.mekapay.online/api/gateway/v1';
const CLIENT_KEY = process.env.MEKAPAY_CLIENT_KEY;
const API_SECRET = process.env.MEKAPAY_API_SECRET; // server-side only, never in a browser or app

// X-MP-Signature = hex HMAC-SHA256(apiSecret, timestamp \n METHOD \n pathWithQuery \n rawBody)
function sign(secret, timestamp, method, pathWithQuery, rawBody) {
  const stringToSign = `${timestamp}\n${method}\n${pathWithQuery}\n${rawBody}`;
  return crypto.createHmac('sha256', secret).update(stringToSign, 'utf8').digest('hex');
}

async function mekapayRequest(method, path, payload) {
  const url = new URL(API_BASE + path);
  const rawBody = payload === undefined ? '' : JSON.stringify(payload);
  const timestamp = String(Math.floor(Date.now() / 1000));
  const res = await fetch(url, {
    method,
    headers: {
      'Content-Type': 'application/json',
      'X-MP-Client-Key': CLIENT_KEY,
      'X-MP-Timestamp': timestamp,
      'X-MP-Signature': sign(API_SECRET, timestamp, method, url.pathname + url.search, rawBody),
    },
    body: rawBody === '' ? undefined : rawBody, // send exactly the bytes you signed
  });
  const json = await res.json().catch(() => null);
  if (!json || !json.success) {
    throw new Error(`MekaPay ${res.status} ${json?.code || ''}: ${json?.message || 'unexpected response'}`);
  }
  return json;
}

// 1. Create (or idempotently re-read) a fixed-amount order for one of your users.
const created = await mekapayRequest('POST', '/orders', {
  merchantOrderId: 'INV-10023',
  userId: 'player_123',
  mode: 'fixed',
  chain: 'bsc',
  currency: 'USDT',
  amount: '100.50',
  expiresInMinutes: 30,
  returnUrl: 'https://merchant.example/deposit/done',
  metadata: { cartId: 'C-778' },
});
const order = created.data;
console.log(created.created ? 'Created order' : 'Order already existed', order.orderId, order.reference);
console.log(`Ask the user to send ${order.amount} ${order.currency} (${order.network}) to ${order.address}`);
console.log('or send them to the payment page:', order.paymentUrl);

// 2. Read it back by your own id (a GET has an empty body).
const found = await mekapayRequest('GET', `/orders?merchantOrderId=${encodeURIComponent(order.merchantOrderId)}`);
console.log('Status:', found.data.status);

Receive and verify callbacks

Each receiver accepts POST /mekapay/callback, verifies the signature and timestamp over the raw body, ignores repeated eventIds and credits each paid order once. The in-memory sets stand in for database tables with unique constraints.

// callback-server.mjs: receive and verify MekaPay callbacks (Node.js 18+, no dependencies)
//   MEKAPAY_API_SECRET=mps_... PORT=3000 node callback-server.mjs
import http from 'node:http';
import crypto from 'node:crypto';

const API_SECRET = process.env.MEKAPAY_API_SECRET;
const PORT = Number(process.env.PORT || 3000);
const TOLERANCE_SEC = 300;

// X-MP-Signature = hex HMAC-SHA256(apiSecret, timestamp + "." + rawBody)
function isValidSignature(rawBody, timestamp, signature) {
  if (!/^\d{1,12}$/.test(timestamp || '') || !/^[0-9a-f]{64}$/i.test(signature || '')) return false;
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SEC) return false;
  const expected = crypto.createHmac('sha256', API_SECRET).update(`${timestamp}.${rawBody}`, 'utf8').digest();
  return crypto.timingSafeEqual(expected, Buffer.from(signature, 'hex'));
}

// Replace these with database tables that have a UNIQUE constraint.
const processedEventIds = new Set();
const creditedOrderIds = new Set();

function handleEvent(event) {
  const { order, deposit } = event.data;
  if (event.event === 'payment.paid') {
    if (creditedOrderIds.has(order.orderId)) return; // credit each order at most once
    creditedOrderIds.add(order.orderId);
    console.log(`Credit ${order.amountReceived} ${order.currency} to ${order.userId} for ${order.merchantOrderId} (${order.orderId})`);
  } else if (event.event === 'payment.expired') {
    console.log(`Order ${order.orderId} expired; received ${order.amountReceived} ${order.currency}`);
  } else if (event.event === 'deposit.unmatched') {
    console.log(`Unmatched deposit of ${deposit.amount} USDT from ${deposit.userId} (${deposit.unmatchedReason}): ${deposit.explorerUrl}`);
  }
}

http.createServer((req, res) => {
  if (req.method !== 'POST' || req.url !== '/mekapay/callback') {
    res.writeHead(404).end();
    return;
  }
  const chunks = [];
  req.on('data', chunk => chunks.push(chunk));
  req.on('end', () => {
    const rawBody = Buffer.concat(chunks).toString('utf8'); // verify the raw bytes, not re-serialised JSON
    if (!isValidSignature(rawBody, req.headers['x-mp-timestamp'], req.headers['x-mp-signature'])) {
      res.writeHead(401).end('invalid signature');
      return;
    }
    const event = JSON.parse(rawBody);
    if (!processedEventIds.has(event.eventId)) {
      processedEventIds.add(event.eventId);
      handleEvent(event);
    }
    res.writeHead(200, { 'Content-Type': 'text/plain' }).end('ok'); // answer 2xx within 10 s
  });
}).listen(PORT, process.env.HOST, () => console.log(`Listening on http://localhost:${PORT}/mekapay/callback`));

Self-test values

Run your own signing code with the test secret mps_AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8 and compare:

InputExpected X-MP-Signature
POST /orders at 179439120059e0d2ed6ce46e58fe8f87885dc1100c3c71c36fe4e7d4ecde00ff503c77a394
GET /orders?merchantOrderId=INV-10023 at 1794391200a1b80522211ffc539ab2c081439d6c6ed9fe122a007d023a69502d3bfea31f93
Callback body at 1794391512c10c65cc2f03d52438d090c3823914dcc18df2f2886f3af11368376d27ad775f

The create-order programs above send exactly the worked-example body. Run at timestamp 1794391200 with the test secret, they produce the first two signatures.

Using a web framework

Verify callbacks over the raw body, before the framework parses JSON:

  • Express: app.post('/mekapay/callback', express.raw({ type: 'application/json' }), handler), then req.body.toString('utf8') is the raw body.
  • Laravel: $request->getContent() and $request->header('X-MP-Signature'); exclude the route from CSRF protection.
  • Django: request.body (bytes) and request.headers['X-MP-Signature']; mark the view @csrf_exempt.
  • Flask: request.get_data() and request.headers['X-MP-Signature'].

Changelog

Changes to the Payment Gateway API and to this documentation.

Payouts (代付) · 12 November 2026

  • New payouts: send USDT from your balance to your users' own wallets. Every payout is reviewed by MekaPay before it is sent, so payouts are not instant; they are usually sent within working hours. Off until MekaPay enables it for your account.
  • New payout signing keys: an Ed25519 key pair you generate on your server. You register only the public key, in the merchant portal with your two-factor code; it can sign payouts 24 hours after MekaPay approves it. Up to 2 keys, for rotation (GET /payout-keys).
  • New endpoints POST /payouts (with the payoutSignature over the payout and its signedAt, which must be within 600 seconds of MekaPay's clock, and the required merchantPayoutId as idempotency key), GET /payouts, GET /payouts/:payoutId and POST /payouts/:payoutId/cancel. Every payout shows the key that signed it (payoutKeyId).
  • Fee 1 USDT on BSC and 3 USDT on TRON, charged on top: the user receives exactly amount and amount + fee is deducted from your balance, credited back if the payout is rejected or cancelled. Minimum 5 USDT. Default limits 5,000 USDT per payout, 20,000 USDT per 24 hours and 5,000 USDT per address per 24 hours.
  • GET /balance adds payoutsEnabled, payoutLimits, payoutUsedLast24h and, per chain, pendingPayouts, payoutFee and payoutMinimum.
  • New callback events payout.completed and payout.rejected. Ignore events you do not know: see Events.
  • New errors PAYOUTS_DISABLED, PAYOUT_KEY_REQUIRED, INVALID_PAYOUT_SIGNATURE, PAYOUTS_PAUSED, DESTINATION_NOT_ALLOWED, DESTINATION_CHECK_UNAVAILABLE, PAYOUT_LIMIT_EXCEEDED, DUPLICATE_MERCHANT_PAYOUT_ID, PAYOUT_NOT_FOUND, PAYOUT_NOT_CANCELLABLE and TRY_AGAIN: see Error codes.

Single-use deposit addresses · 12 November 2026

  • Orders now get a single-use address from your account's address pool, valid for that order only. New order field addressType: single_use or permanent. See Deposit addresses.
  • Users who already had a permanent address keep it, and transfers to it are reported as before.
  • After an order's window a single-use address rests for 60 minutes. A transfer that arrives meanwhile is reported as deposit.unmatched with that order and its user, and is never credited automatically.
  • Show the address for the current payment only; never save it for the user. The payment page says so.
  • The default time limit for new accounts is 20 minutes (was 30). Existing accounts keep theirs.

Balances and settlements · 11 November 2026

  • New GET /balance: your balance per chain, the confirmed deposits minus your deposit fee and your settlements.
  • New settlement addresses: up to 3 of your own wallets per chain, added in the merchant portal with your two-factor code, approved by MekaPay and usable 48 hours after approval (GET /settlement-addresses).
  • New settlements: POST /settlements, GET /settlements, GET /settlements/:settlementId and POST /settlements/:settlementId/cancel. Fee 1 USDT on BSC and 3 USDT on TRON, deducted from the amount; minimum 10 USDT. Off until MekaPay enables it for your account.
  • New callback events settlement.completed and settlement.rejected. Ignore events you do not know: see Events.

Manual approval by staff · 11 November 2026

  • MekaPay staff can mark a pending or expired order as paid by hand, for example for integration tests or after a support decision on an underpaid order. You receive a normal signed payment.paid with deposit: null: see Manual approval.
  • New order field manualApproval (approvedAt, amount, amountOnChain), present only on such orders. amountReceived is the approved total; credit by the Crediting rule as usual.

IP whitelist required · 11 November 2026

  • API requests are accepted only from the server IP addresses whitelisted for your account. Give MekaPay support the static public IPv4 and IPv6 addresses or ranges your servers call from: see IP whitelist.
  • New errors 403 IP_WHITELIST_REQUIRED and 403 IP_NOT_WHITELISTED, with the caller's address in ip.
  • The whitelist applies to the older client API (/api/client/v1) as well. The payment page and callbacks are not affected.
  • On request, merchant portal access can also be restricted to listed IP addresses.

v1.0 · 11 November 2026

First release of the Payment Gateway API.

  • Signed merchant requests with X-MP-Client-Key, X-MP-Timestamp and X-MP-Signature.
  • Endpoints: GET /ping, POST /orders, GET /orders/:orderId, GET /orders, POST /orders/:orderId/cancel, GET /deposits.
  • Fixed-amount and any-amount payment modes.
  • USDT on BNB Smart Chain (BEP20) and TRON (TRC20), with a permanent deposit address per user and chain.
  • Hosted payment page at https://mekapay.online/pay/<orderId> in English and Simplified Chinese.
  • Signed callbacks payment.paid, payment.expired and deposit.unmatched, with retries over about 46 hours and resend from the merchant portal.
  • The transfer's block time decides which order it belongs to. A transfer sent in time that confirms after the order expired or was cancelled pays it if it completes it, else it is reported as deposit.unmatched (after_expiry or order_cancelled). Every confirmed deposit is reported; credit orders by their running total (amountReceived minus what you already credited).
  • Orders expire only after MekaPay's chain scanner has caught up with the order window.
  • Fair callback delivery across merchants, with slow endpoints deferred; resend guards 409 RESEND_PENDING and 429 RESEND_LIMIT.
  • Request bodies must be JSON (415 UNSUPPORTED_MEDIA_TYPE otherwise).

The older contract-based client API (/api/client/v1) is documented separately. Apart from the IP whitelist, it is unchanged.