One REST API for pay-ins, payouts and wallet routing
Seven JSON endpoints, one API key, signed webhooks. Send us an order — the routing engine picks a healthy agent wallet, respects every limit and cooldown, and pushes the result back to you within about a second.
Base URL
https://xpaybd.com/api/v1
Auth
key field inside the JSON body
Webhook
HMAC-SHA256 X-Signature header
Quick start
- 1
Get your key & salt
Generate the API key and salt in your portal. Both are shown once — store them in your server vault.
- 2
Whitelist your server IP
Only whitelisted IPs may call the API. Add every outbound IP of your backend.
- 3
Create an order
POST a payin or payout with your own client_txn_id. The engine picks the wallet for you.
- 4
Receive the webhook
We push the final result to your webhook URL and sign it. Verify, then update your ledger.
Core concepts
Eight words carry the whole API. Read these once and every endpoint below reads like plain English.
API user
That's you. One API key + salt pair identifies your platform on every call and every webhook we send back.
Agent
The wallet owner behind the money movement. Agents connect their bKash / Nagad accounts in the agent dashboard; you never see or pick an individual wallet.
Channel (bank_id)
The rail an order runs on — bKash Personal, bKash Agent, Nagad Personal / Agent or Nagad Merchant. You choose the rail, the engine chooses the wallet on it.
Order
One pay-in (money coming in) or payout (money going out). Every order carries your own client_txn_id plus our order_id.
client_txn_id
Your idempotency key. Reuse it on a retry and you get 409 instead of a duplicate order; it frees up again after 24 hours.
Routing engine
Picks a healthy wallet using balance, per-transaction limits, daily limits and cooldown, then rotates traffic evenly across wallets.
Webhook
The signed callback that tells you the final state. It is the source of truth — polling order_status is only a fallback.
api_user_fee
The flat platform fee added per transaction, returned on the order and in the webhook so your ledger always balances.
Authentication
Every endpoint is POST with a JSON body and lives under https://xpaybd.com/api/v1. There is no Authorization header — pass your key as the key field in the body. A bad or missing key returns 401 Unauthorized.
POST https://xpaybd.com/api/v1/wallet_balance
Content-Type: application/json
{ "key": "YOUR_API_KEY" }- Key — authenticates requests. Server-side only; never ship it to a browser or mobile app.
- Salt — the HMAC secret used to verify webhook signatures. Different job, same rule: keep it secret.
- Rotation — regenerating invalidates the old pair immediately, so deploy the new values first.
- IP whitelist — calls from unlisted IPs are rejected before they reach the routing engine.
Channels & limits
Pass the channel you want as bank_id. Amounts outside the per-transaction range below are rejected. Check live headroom with /available_limits before sending large batches — it returns overall and per-gateway capacity.
| bank_id | Channel | Services | Min | Max |
|---|---|---|---|---|
| 3 | bKash Personal | PayIn / PayOut | ৳200 | ৳50,000 |
| 2 | bKash Agent | PayIn / PayOut | ৳200 | ৳50,000 |
| 1 | Nagad Personal | PayIn / PayOut | ৳200 | ৳50,000 |
| 1 | Nagad Agent | PayIn / PayOut | ৳200 | ৳50,000 |
| 4 | Nagad Merchant (non-API) | PayIn only | ৳200 | ৳100,000 |
Order lifecycle
Both directions follow the same shape: you create an order, the routing engine does the work, and a signed webhook closes the loop. Average round trip is about one second.
PayIn — money in
- 1
You call /create_payin_order
Send amount, bank_id and your client_txn_id.
- 2
Engine picks a wallet
Balance, limits, cooldown and rotation are checked in one pass — roughly a second.
- 3
Customer pays
We return the deposit account (or a hosted checkout link) and the order sits pending for 1 hour.
- 4
You submit the TrxID
Call /update_payin_order with the customer's bank_txn_id so we can match the deposit.
- 5
Webhook fires
payin_success or payin_failed lands on your endpoint, signed with X-Signature.
PayOut — money out
- 1
You call /create_payout_order
Send amount, receiver account, bank_id and client_txn_id.
- 2
Engine reserves the wallet
Only wallets with real balance and free limit are eligible.
- 3
Money is sent
The agent wallet disburses to the receiver account.
- 4
Instant reconciliation
We fetch the wallet statement and match the transfer before calling it done.
- 5
Webhook fires
payout_success (with bank_txn_id) or payout_failed with a reason.
API playground
Live requestsTest every endpoint right here with your own API key — no Postman needed. Requests are relayed server-side to https://xpaybd.com/api/v1 and the raw response is shown exactly as your backend will receive it. Your key stays in this browser tab only and is never stored on our servers.
Your unique reference — alphanumeric/hyphen, max 36 chars. Reusable after 24 hours.
Collection amount (positive).
Target channel: 3 bKash Personal, 2 bKash Agent, 1 Nagad Personal/Agent, 4 Nagad Merchant (non-API, payin only). See channel limits.
Required when the bank has the checkout page enabled. http/https only, max 2048 chars; localhost and private IPs are rejected.
{
"key": "••••••••",
"client_txn_id": "payin-001",
"amount": 500,
"bank_id": 1,
"redirect_url": "https://your-site.com/thank-you"
}// Send a request to see the live response here.
curl -X POST 'https://xpaybd.com/api/v1/create_payin_order' \
-H 'Content-Type: application/json' \
-d '{
"key": "YOUR_API_KEY",
"client_txn_id": "payin-001",
"amount": 500,
"bank_id": 1,
"redirect_url": "https://your-site.com/thank-you"
}'Endpoints
/api/v1/create_payin_orderCreate PayIn OrderOpen a collection order. The engine picks a healthy agent wallet and returns the deposit account (plus a hosted checkout link when the bank supports it).
| Field | Type | Required | Description |
|---|---|---|---|
| key | string | Yes | Your API key. |
| client_txn_id | string | Yes | Your unique reference — alphanumeric/hyphen, max 36 chars. Reusable after 24 hours. |
| amount | number | Yes | Collection amount (positive). |
| bank_id | integer | Yes | Target channel: 3 bKash Personal, 2 bKash Agent, 1 Nagad Personal/Agent, 4 Nagad Merchant (non-API, payin only). See channel limits. |
| redirect_url | string | Conditional | Required when the bank has the checkout page enabled. http/https only, max 2048 chars; localhost and private IPs are rejected. |
curl -X POST 'https://xpaybd.com/api/v1/create_payin_order' \
-H 'Content-Type: application/json' \
-d '{
"key": "YOUR_API_KEY",
"client_txn_id": "payin-001",
"amount": 500,
"bank_id": 1,
"redirect_url": "https://your-site.com/thank-you"
}'{
"status_code": 201,
"message": "Payin order created successfully",
"status": "success",
"data": {
"order_id": 1,
"client_txn_id": "payin-001",
"amount": 500,
"total_amount": 500,
"bank_name": "Nagad",
"bank_account": "0171XXXXXXX",
"checkout_url": "https://checkout.example.com/checkout/payin/...",
"expired_at": "2026-08-04T17:23:05.992Z",
"status": "pending"
}
}{
"status_code": 422,
"message": "amount must be a positive number",
"status": "error"
}{
"status_code": 400,
"message": "No available bank account found for payin",
"status": "error"
}Note — checkout_url is only returned when the target bank has the checkout page enabled. Send the customer there: they pay into the shown account and submit their TrxID. PayIn orders expire after 1 hour by default (see expired_at) and then auto-move to expired.
/api/v1/update_payin_orderUpdate PayIn OrderSubmit the customer's bank transaction ID against a pending payin. On a match the order flips to success and wallet settlement runs.
| Field | Type | Required | Description |
|---|---|---|---|
| key | string | Yes | Your API key. |
| client_txn_id | string | One of | Your reference (max 36 chars). |
| order_id | integer | One of | System order ID. |
| txn_id | string | Yes | Payment reference from the sender's bank (max 100 chars). |
| mobile_no | string | No | Sender mobile number (7–15 digits). Left blank if omitted. |
curl -X POST 'https://xpaybd.com/api/v1/update_payin_order' \
-H 'Content-Type: application/json' \
-d '{
"key": "YOUR_API_KEY",
"client_txn_id": "payin-001",
"txn_id": "A1B2C3D4E5"
}'{
"status_code": 200,
"message": "Payin order updated successfully",
"status": "success",
"data": {
"order_id": 42,
"client_txn_id": "payin-001",
"bank_txn_id": "A1B2C3D4E5",
"paid_amount": 500,
"requested_amount": 500,
"status": "success",
"api_user_fee": 15.00,
"created_at": "2026-08-04T10:00:00.000Z",
"updated_at": "2026-08-04T10:05:00.000Z"
}
}{
"status_code": 404,
"message": "Order not found with this client_txn_id",
"status": "error",
"data": {}
}{
"status_code": 400,
"message": "Order cannot be updated in its current status: success",
"status": "error"
}Note — Only orders in pending status can be updated.
/api/v1/create_payout_orderCreate PayOut OrderQueue a disbursement. Funds leave a connected agent wallet and land on the customer's mobile number.
| Field | Type | Required | Description |
|---|---|---|---|
| key | string | Yes | Your API key. |
| client_txn_id | string | Yes | Unique reference, max 36 chars. Reusable after 24 hours. |
| mobile_no | string | Yes | Recipient mobile number (7–15 digits). |
| amount | number | Yes | Payout amount (positive). |
| bank_id | integer | Yes | Target channel: 3 bKash Personal, 2 bKash Agent, 1 Nagad Personal/Agent, 4 Nagad Merchant (non-API, payin only). See channel limits. |
curl -X POST 'https://xpaybd.com/api/v1/create_payout_order' \
-H 'Content-Type: application/json' \
-d '{
"key": "YOUR_API_KEY",
"client_txn_id": "order-001",
"mobile_no": "01712345678",
"amount": 1000,
"bank_id": 1
}'{
"status_code": 201,
"message": "Payout order created and queued successfully",
"status": "success",
"data": {
"client_txn_id": "order-001",
"amount": 1000,
"mobile_no": "01712345678",
"status": "queue",
"agent_user_id": 5
}
}{
"status_code": 201,
"message": "Payout order failed — insufficient balance (need 1010, have 500)",
"status": "error",
"data": {
"client_txn_id": "order-001",
"status": "failed",
"agent_user_id": null,
"fail_reason": "Insufficient balance (including charges)"
}
}{
"status_code": 409,
"message": "client_txn_id already exists",
"status": "error"
}{
"status_code": 400,
"message": "Bank is currently not active",
"status": "error"
}Note — Charge slabs are applied automatically — your wallet is debited amount + charges. If the balance falls short, the order is created with a failed status instead of being rejected.
/api/v1/fail_payout_orderFail PayOut OrderCancel a payout that is still sitting in the queue. Once the dispatcher picks it up it can no longer be failed.
| Field | Type | Required | Description |
|---|---|---|---|
| key | string | Yes | Your API key. |
| client_txn_id | string | One of | Your reference — only orders from the last 24 hours are searched. |
| order_id | integer | One of | System order ID. |
curl -X POST 'https://xpaybd.com/api/v1/fail_payout_order' \
-H 'Content-Type: application/json' \
-d '{
"key": "YOUR_API_KEY",
"client_txn_id": "order-001"
}'{
"status_code": 200,
"status": "success",
"message": "Payout order failed successfully",
"data": {
"order_id": 123,
"client_txn_id": "order-001",
"status": "failed"
}
}{
"status_code": 400,
"message": "Order is already processing",
"status": "error"
}{
"status_code": 422,
"message": "Either client_txn_id or order_id is required",
"status": "error"
}Note — Only queued payouts that are not being processed can be failed. A failed order is never dispatched afterwards.
/api/v1/order_statusOrder StatusPoll the current state of any order — payin or payout.
| Field | Type | Required | Description |
|---|---|---|---|
| key | string | Yes | Your API key. |
| client_txn_id | string | One of | Your reference. |
| order_id | integer | One of | System order ID. |
curl -X POST 'https://xpaybd.com/api/v1/order_status' \
-H 'Content-Type: application/json' \
-d '{
"key": "YOUR_API_KEY",
"client_txn_id": "payin-001"
}'{
"status_code": 200,
"status": "success",
"data": {
"order_id": 42,
"order_type": "payout",
"client_txn_id": "payout-001",
"bank_txn_id": "D9Q8UI5",
"paid_amount": 500,
"requested_amount": 500,
"status": "success",
"reason": null,
"api_user_fee": 10,
"created_at": "2026-02-19T09:00:00.000Z",
"updated_at": "2026-02-19T09:05:00.000Z",
"sender_acc": "01923043979",
"receiver_acc": "01712345678"
}
}{
"status_code": 404,
"message": "Order not found",
"status": "error"
}/api/v1/wallet_balanceWallet BalanceRead the live wallet balance of the authenticated API user.
| Field | Type | Required | Description |
|---|---|---|---|
| key | string | Yes | Your API key. |
curl -X POST 'https://xpaybd.com/api/v1/wallet_balance' \
-H 'Content-Type: application/json' \
-d '{ "key": "YOUR_API_KEY" }'{
"status_code": 200,
"status": "success",
"data": { "balance": 1500.00 }
}{
"status_code": 401,
"message": "Unauthorized Access",
"status": "error"
}/api/v1/available_limitsAvailable LimitsVault capacity across every active agent wallet — payin headroom, payout balance and limit, plus a per-gateway breakdown. Call it before sizing a batch.
| Field | Type | Required | Description |
|---|---|---|---|
| key | string | Yes | Your API key. |
curl -X POST 'https://xpaybd.com/api/v1/available_limits' \
-H 'Content-Type: application/json' \
-d '{ "key": "YOUR_API_KEY" }'{
"status_code": 200,
"status": "success",
"message": "Vault capacity fetched successfully",
"data": {
"currency": "BDT",
"payin": {
"status": "available",
"available_capacity": 2500000,
"minimum_order_amount": 500,
"maximum_order_amount": 100000
},
"payout": {
"status": "available",
"available_balance": 1800000,
"available_limit": 1500000,
"minimum_order_amount": 500,
"maximum_order_amount": 100000
},
"gateway_capacity": [
{
"gateway_type": "bkash_agent",
"payin_available": 1000000,
"payout_available_balance": 700000,
"payout_available_limit": 600000,
"status": "available"
}
],
"updated_at": "2026-08-13T23:42:00+06:00"
}
}Webhooks
Set your webhook URL in the portal (leave it empty to disable). Every final order state is pushed as JSON with an X-Signature header — HMAC-SHA256 of the raw request payload keyed with your salt. Verify it before you trust the payload, and always respond 200 quickly.
{
"event": "payin_success",
"data": {
"order_id": 1,
"order_type": "payin",
"client_txn_id": "payin-001",
"bank_txn_id": "DXXXXXXXX2",
"paid_amount": 5000,
"requested_amount": 5100,
"reason": null,
"status": "success",
"api_user_fee": 101,
"created_at": "2026-08-03T18:25:24.000Z",
"updated_at": "2026-08-03T18:25:24.000Z",
"sender_acc": "01712345678",
"receiver_acc": "01987262113"
}
}// Node.js — verify the X-Signature header on every webhook
import crypto from "node:crypto";
app.post("/webhooks/pay2capital", express.raw({ type: "*/*" }), (req, res) => {
const raw = req.body; // the RAW request payload, not JSON.parse'd
const expected = crypto
.createHmac("sha256", process.env.PAY2CAPITAL_SALT)
.update(raw)
.digest("hex");
const received = req.get("X-Signature") ?? "";
const ok =
received.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
if (!ok) return res.status(401).send("bad signature");
const event = JSON.parse(raw.toString("utf8"));
// handle event.event: payin_success | payin_failed | payout_success | payout_failed
res.sendStatus(200);
});Order states
pendingPayIn created, waiting for the customer to pay and submit a TrxID.
queuePayOut accepted, waiting for the dispatcher to pick a wallet.
processingThe dispatcher is executing the transfer — no longer cancellable.
successMatched and settled. A webhook is fired.
failedRejected or unmatched. reason carries the cause.
expiredPayIn window closed (1 hour by default) with no matching payment.
Common error codes
| 401 | Unauthorized — invalid or missing API key. |
| 403 | Account deactivated — contact customer care. |
| 404 | Resource not found (bank or order). |
| 409 | Conflict — duplicate client_txn_id. |
| 422 | Validation error — read the message for the offending field. |
| 500 | Internal server error — retry with the same client_txn_id. |
Retries are safe: reuse the same client_txn_id and a duplicate returns 409 instead of creating a second order. The same reference becomes reusable again after 24 hours.
Go-live checklist
Tick every line before you switch real traffic on. These eight points cover every support ticket we normally see in week one.
- Key and salt stored server-side in a vault — never in front-end code.
- All backend outbound IPs whitelisted in the portal.
- client_txn_id is unique per order and stored before you call us.
- Webhook endpoint is public, responds 200 fast, and verifies X-Signature on the raw body.
- Webhook handler is idempotent — the same event may arrive more than once.
- Amounts validated against the channel min/max before you call the API.
- order_status polling wired as a fallback for missed webhooks.
- Errors 401 / 409 / 422 / 400 mapped to your own user-facing messages.
Need a sandbox key or help going live?
Our integration team reviews your flow and whitelists your servers the same day.

