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 | Gateway | PayIn | PayOut | Min | Max |
|---|---|---|---|---|---|---|
| 3 | bKash Personal | bkash | Yes | Yes | ৳200 | ৳50,000 |
| 2 | bKash Agent | bkash_agent | Yes | Yes | ৳200 | ৳50,000 |
| 1 | Nagad (Agent) | nagad | Yes | Yes | ৳200 | ৳50,000 |
| 5 | Nagad Personal | nagad_personal | Yes | Yes | ৳200 | ৳50,000 |
| 4 | Nagad Merchant (non-API) | nagad_merchant | Yes | — No | ৳200 | ৳100,000 |
| Direction | Endpoint | Accepted bank_id |
|---|---|---|
| PayIn | /create_payin_order | 3bKash Personal2bKash Agent1Nagad (Agent)5Nagad Personal4Nagad Merchant (non-API) |
| PayOut | /create_payout_order | 3bKash Personal2bKash Agent1Nagad (Agent)5Nagad Personal |
Nagad Merchant (4) is a non-API collection channel — sending it to /create_payout_order returns 400.
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 (must support payin): 3 bKash Personal, 2 bKash Agent, 1 Nagad, 5 Nagad Personal, 4 Nagad Merchant (non-API). See the bank_id map.
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"
}'Signature & key tools
Runs in your browserBuild a webhook signature from any payload, compare it with one you received, and confirm your credentials work before you write a single line of code.
Webhook signature generator & verifier
Everything here is computed inside your browser with WebCrypto. Your salt never leaves this tab.
API key checker
The check calls Wallet Balance — a read-only endpoint — so nothing is created or charged. The key is used for that single request and never stored.
Ready-made client
Zero dependenciesOne file that wraps every endpoint plus webhook signature verification. Drop it into your project, set your key from an environment variable, and you are integrated.
// pay2capital.js — generated client for https://xpaybd.com/api/v1
// Usage: const p2c = createClient({ key: process.env.P2C_KEY, salt: process.env.P2C_SALT });
import { createHmac, timingSafeEqual } from "node:crypto";
const BASE = "https://xpaybd.com/api/v1";
export function createClient({ key, base = BASE, timeoutMs = 20000 }) {
if (!key) throw new Error("API key is required");
async function call(path, payload = {}) {
const res = await fetch(base + path, {
method: "POST",
headers: { "content-type": "application/json", accept: "application/json" },
body: JSON.stringify({ key, ...payload }),
signal: AbortSignal.timeout(timeoutMs),
});
const text = await res.text();
let data;
try {
data = JSON.parse(text);
} catch {
data = { raw: text };
}
if (!res.ok) {
const err = new Error(data?.message || `Request failed with ${res.status}`);
err.status = res.status;
err.body = data;
throw err;
}
return data;
}
return {
call,
/** POST /create_payin_order — Create PayIn Order. Fields: client_txn_id, amount, bank_id, redirect_url */
createPayinOrder: (payload) => call("/create_payin_order", payload),
/** POST /update_payin_order — Update PayIn Order. Fields: client_txn_id, order_id, txn_id, mobile_no */
updatePayinOrder: (payload) => call("/update_payin_order", payload),
/** POST /create_payout_order — Create PayOut Order. Fields: client_txn_id, mobile_no, amount, bank_id */
createPayoutOrder: (payload) => call("/create_payout_order", payload),
/** POST /fail_payout_order — Fail PayOut Order. Fields: client_txn_id, order_id */
failPayoutOrder: (payload) => call("/fail_payout_order", payload),
/** POST /order_status — Order Status. Fields: client_txn_id, order_id */
orderStatus: (payload) => call("/order_status", payload),
/** POST /wallet_balance — Wallet Balance. Fields: none */
walletBalance: (payload) => call("/wallet_balance", payload),
/** POST /available_limits — Available Limits. Fields: none */
availableLimits: (payload) => call("/available_limits", payload),
};
}
/** Verify the X-Signature header of an incoming webhook against the RAW body string. */
export function verifyWebhook(rawBody, signature, salt) {
const expected = createHmac("sha256", salt).update(rawBody).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(String(signature || ""));
return a.length === b.length && timingSafeEqual(a, b);
}
A complete Android Studio project: the sdk library module wrapping every endpoint, the hosted bKash / Nagad checkout screen, and a runnable sample app. Build an AAR with ./gradlew :sdk:assembleRelease or include the module directly.
include(":sdk")
project(":sdk").projectDir = file("pay2capital-android-sdk/sdk")
// app/build.gradle.kts
dependencies { implementation(project(":sdk")) }Never ship a live API key inside an APK. Create the order on your backend and pass only the checkout_url to the app. Treat a submitted TrxID as a claim — settle only after order_status or the webhook confirms it.
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 (must support payin): 3 bKash Personal, 2 bKash Agent, 1 Nagad, 5 Nagad Personal, 4 Nagad Merchant (non-API). See the bank_id map. |
| 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",
"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": 400,
"message": "Order has expired",
"status": "error",
"data": {
"order_id": 42,
"client_txn_id": "payin-001",
"bank_txn_id": "A1B2C3D4E5",
"paid_amount": 500,
"requested_amount": 500,
"status": "expired",
"api_user_fee": 15.00,
"created_at": "2026-08-04T10:00:00.000Z",
"updated_at": "2026-08-04T10:10:00.000Z"
}
}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 (must support payout): 3 bKash Personal, 2 bKash Agent, 1 Nagad, 5 Nagad Personal. Nagad Merchant (4) is payin only. See the bank_id map. |
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",
"amount": 1000,
"mobile_no": "01712345678",
"status": "failed",
"agent_user_id": null,
"fail_reason": "Insufficient balance (including charges)"
}
}{
"status_code": 422,
"message": "client_txn_id is required",
"status": "error"
}{
"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": 400,
"message": "Order cannot be failed in its current status: success",
"status": "error"
}{
"status_code": 404,
"message": "Order not found",
"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.
Live status & go-live checklist
Tick every line before you switch real traffic on — your progress is saved in this browser. These points cover every support ticket we normally see in week one.
Need a sandbox key or help going live?
Our integration team reviews your flow and whitelists your servers the same day.

