API reference
bKash and Nagad collection and payout in two endpoints: a hosted checkout for deposits, a REST call for payouts, and one signed webhook for each.
Used only for the live "Try it" requests below. Kept in this tab's memory, never stored.
Integration summary#
Copy-paste for a partner's onboarding form. Your public key is the merchant identifier - there is no separate merchant ID field anywhere in the API.
Payment channel name : SoftZPayAPI domain : https://api.softzpay.onlineDocumentation : https://panel.softzpay.online/docsMerchant identifier : <your public key, pk_live_...>Deposit methods : bKash, Nagad (hosted checkout)Payout methods : bKash, Nagad (API, no manual step)Currency : BDTPer-transaction limit: none enforced by the API (amount > 0)Checkout link expiry : 30 minutesCallback source IP : <ask your account manager>Callback format : POST JSON, HMAC-SHA256 signedCallback response : any HTTP 2xx, body ignoredQuick start#
- 1Generate an API key. The secret is shown once - store it server-side only.
- 2
POST /api/v1/payment/create-paymentand redirect the customer to thepayment_urlyou get back. - 3The customer pays and is returned to your
callback_url. Use that only to show a screen - never to credit an order. - 4Credit the order when the webhook arrives with
status: 1, or whentrack-statusreturnscompleted. Always fulfil againstamount. - 5For payouts,
POST /api/v1/withdrawal/create-withdrawaland wait for its webhook - samestatus: 1rule.
Authentication#
Every request goes over HTTPS to https://api.softzpay.online and carries both keys as headers. Never expose the secret key in client-side code - call these endpoints only from your own server.
X-Authorization: pk_live_xxxxxxxxxxxxxxxxxxxxxxxxX-Authorization-Secret: sk_live_•••••••••••••••••••••••• (shown once, at generation)Missing, wrong, or revoked credentials return 401.
Create a payment#
Creates a payment request and returns a hosted checkout URL to redirect your customer to.
/api/v1/payment/create-payment| Field | Type | Required | Description |
|---|---|---|---|
| amount | string | required | Decimal amount, e.g. "1100" or "1100.50". Max 2 decimal places, must be > 0. |
| reference | string | required | Your unique order ID. Must be unique per merchant - reused values return 409. |
| callback_url | string | required | Where the customer's browser is redirected after the payment attempt. |
| webhook_url | string | optional | Server-to-server URL that receives the final status. See Payment webhook below. |
| cust_name | string | required | Customer's name. |
| cust_phone | string | optional | Customer's phone number. |
| currency | string | optional | "BDT" - the only accepted value, case-insensitive. Defaults to BDT when omitted. |
| deposit_method | string | optional | "bkash" | "nagad" (case-insensitive). Skips the method picker and opens checkout straight on that provider, locked - the customer cannot switch away from it. Any other value returns 400. |
| type | string | optional | Provider flow label, e.g. "P2A". Reporting metadata only - stored on the payment and shown in the panel. Not required for deposit_method to work. |
Example request
curl -X POST "https://api.softzpay.online/api/v1/payment/create-payment" \ -H "X-Authorization: pk_live_xxxxxxxxxxxxxxxxxxxxxxxx" \ -H "X-Authorization-Secret: sk_live_•••••••••••••••••••••••• (shown once, at generation)" \ -H "Content-Type: application/json" \ -d '{ "amount": "1100", "reference": "inv_smart_001", "callback_url": "https://example.com/callback", "webhook_url": "https://example.com/webhook", "cust_name": "Jhon Doe" }'Example response
{ "success": true, "message": "Payment request created successfully", "data": { "request_id": "aWJ2X3Rva2VuX2V4YW1wbGU", "amount": "1100", "reference": "inv_smart_001", "currency": "BDT", "issue_time": "2026-07-16 04:15:00", "payment_url": "https://pay.softzpay.online/checkout/aWJ2X3Rva2VuX2V4YW1wbGU" }}Redirect the customer to payment_url. Keep request_id if you like, but every later lookup and every webhook is keyed on your own reference. See Errors for the failure responses.
Try it
Checkout return#
When the customer finishes at the checkout, their browser is redirected back to the callback_url you supplied, with three query parameters appended:
https://example.com/callback?payment=completed&reference=inv_smart_001&request_id=aWJ2X3Rva2VuX2V4YW1wbGUpayment is completed, failed or pending; reference and request_id echo what create-payment gave you.
This redirect is browser-supplied and is for showing a result screen only. Never credit an order on it - credit on the webhook, or on a server-side track-status call. payment=pending is normal: it means the payment is still being confirmed and the webhook will follow.
Track payment status#
Poll this endpoint to check a payment's current status using your own reference.
/api/v1/payment/track-status/:referencecurl "https://api.softzpay.online/api/v1/payment/track-status/inv_smart_001" \ -H "X-Authorization: pk_live_xxxxxxxxxxxxxxxxxxxxxxxx" \ -H "X-Authorization-Secret: sk_live_•••••••••••••••••••••••• (shown once, at generation)"{ "success": true, "data": { "request_id": "aWJ2X3Rva2VuX2V4YW1wbGU", "amount": "1100.00", "original_amount": null, "amount_adjusted": false, "payment_method": "bkash", "reference": "inv_smart_001", "cust_name": "Jhon Doe", "cust_phone": null, "note": null, "reject_msg": null, "payment_method_trx": "TX123456", "status": "pending" }}Here status is a string - pending, completed or rejected. The webhook sends the same outcome as a number instead; see Status reference.
Try it
Enter your public and secret key above to send a live request.
Payment webhook#
If you supplied a webhook_url, we POST to it once the payment reaches a final state. Pending payments never fire a webhook.
{ "signature": "6294408b326cf940cad20816cda5aa96c234fcabfeef49d013be6e6daeb9514e", "data": { "request_id": "aWJ2X3Rva2VuX2V4YW1wbGU", "amount": "1100.00", "original_amount": null, "amount_adjusted": false, "payment_method": "bkash", "reference": "inv_smart_001", "cust_name": "Jhon Doe", "cust_phone": null, "note": null, "reject_msg": null, "payment_method_trx": "TX123456", "status": 1, "status_name": "completed" }}| Field | What it means |
|---|---|
| reference | Your order ID - use it as the idempotency key on your side. |
| amount | What the customer actually paid and what was credited to your balance. Always fulfil against this. Always 2 decimal places. |
| original_amount | Null unless the amount was adjusted; then it holds the amount you originally requested. Informational only. |
| amount_adjusted | True when amount differs from what you requested (see below). False in the normal case. |
| status | 1 = completed, 3 = rejected. Treat only 1 as success. |
| status_name | Human-readable form of status - "completed" or "rejected". |
| reject_msg | Reason on a rejection, when one was recorded. Null on success. |
| payment_method_trx | The MFS transaction ID the customer's payment came through with. |
Responding
Reply with any HTTP 2xx - the body is never read. We wait 5 seconds, make one attempt, and never retry; if your endpoint is down, fall back to track-status polling or ask an admin to resend it. The same webhook can arrive twice (an admin resend, or a correction to an already final payment), so key your handler on reference and let a later delivery with a different status win.
Verifying the signature
signature is an HMAC-SHA256 hex digest of JSON.stringify(data), keyed with your secret key. The same value is also sent in the X-Signature header. Recompute it against the data object exactly as received and compare before trusting the payload:
const crypto = require("crypto"); function verify(payload, secretKey) { const expected = crypto .createHmac("sha256", secretKey) .update(JSON.stringify(payload.data)) .digest("hex"); return expected === payload.signature;}The same function verifies withdrawal webhooks unchanged.
Adjusted amounts
If a customer pays a different amount than you requested, the deposit is held for admin review - unless auto-adjustment is enabled on your account (off by default), in which case it completes at the amount actually paid with amount_adjusted: true and original_amount holding what you requested. Either way amount is always the credited figure.
Create a withdrawal#
Requests a payout from your available balance to an MFS wallet. The full amount is delivered to the destination and any fee is charged on top, so amount + fee is held from your available balance immediately.
/api/v1/withdrawal/create-withdrawal| Field | Type | Required | Description |
|---|---|---|---|
| amount | string | required | Decimal amount to pay out, e.g. "500" or "500.50". Delivered in full to the destination; the fee is added on top. |
| reference | string | required | Your unique withdrawal ID. Must be unique per merchant - reused values return 409. |
| currency | string | required | "BDT" - the only accepted value, case-insensitive. |
| payout_method | string | required | "bkash" | "nagad" (case-insensitive) - where the payout is sent. Reported back in lowercase. |
| payout_msisdn | string | required | Destination wallet number, e.g. "01700000000". Any spelling is accepted - "+8801700000000", "8801700000000" and "1700000000" all resolve to the same number - and it is always stored and reported back in the local 11-digit form. |
| webhook_url | string | optional | Server-to-server URL that receives the final status. See Withdrawal webhook below. |
Example request
curl -X POST "https://api.softzpay.online/api/v1/withdrawal/create-withdrawal" \ -H "X-Authorization: pk_live_xxxxxxxxxxxxxxxxxxxxxxxx" \ -H "X-Authorization-Secret: sk_live_•••••••••••••••••••••••• (shown once, at generation)" \ -H "Content-Type: application/json" \ -d '{ "amount": "500", "reference": "wd_smart_001", "currency": "BDT", "payout_method": "bkash", "payout_msisdn": "01700000000", "webhook_url": "https://example.com/webhook" }'Example response
{ "success": true, "message": "Withdrawal request created successfully", "data": { "request_id": "aWJ2X3Rva2VuX2V4YW1wbGU", "amount": "500", "fee": "0.00", "net_amount": "500.00", "total_debit": "500.00", "reference": "wd_smart_001", "currency": "BDT", "payout_method": "bkash", "payout_msisdn": "01700000000", "issue_time": "2026-07-18 04:15:00", "status": "pending" }}| Amount field | What it means |
|---|---|
| amount | What you asked us to pay out. |
| fee | Withdrawal fee at your configured rate, charged on top of amount. |
| net_amount | What the destination wallet receives. Always equal to amount under this fee model - use it as the payout figure. |
| total_debit | What leaves your available balance: amount + fee. Use it for balance reconciliation. |
A payout for more than your available balance is rejected with 409 INSUFFICIENT_BALANCE and nothing is held. See Errors.
Try it
This holds the amount from your real available balance immediately (a live WITHDRAWAL_HOLD) - cancel it from the panel's Withdrawals page afterward if you don't want it to actually reach an admin for approval.
Track withdrawal status#
Poll this endpoint to check a withdrawal's current status using your own reference.
/api/v1/withdrawal/track-status/:referencecurl "https://api.softzpay.online/api/v1/withdrawal/track-status/wd_smart_001" \ -H "X-Authorization: pk_live_xxxxxxxxxxxxxxxxxxxxxxxx" \ -H "X-Authorization-Secret: sk_live_•••••••••••••••••••••••• (shown once, at generation)"{ "success": true, "data": { "request_id": "aWJ2X3Rva2VuX2V4YW1wbGU", "amount": "500.00", "fee": "0.00", "net_amount": "500.00", "total_debit": "500.00", "reference": "wd_smart_001", "payout_method": "bkash", "payout_msisdn": "01700000000", "note": null, "reject_msg": null, "payout_method_trx": null, "status": "pending" }}As with payments, status is a string here (pending, completed, rejected) and a number in the webhook.
Try it
Enter your public and secret key above to send a live request.
Withdrawal webhook#
Identical mechanics to the payment webhook - one POST on the final state, HMAC-SHA256 signed, any HTTP 2xx accepted, body ignored, one attempt, no automatic retry. Only the fields differ.
{ "signature": "6294408b326cf940cad20816cda5aa96c234fcabfeef49d013be6e6daeb9514e", "data": { "request_id": "aWJ2X3Rva2VuX2V4YW1wbGU", "amount": "500.00", "fee": "0.00", "net_amount": "500.00", "total_debit": "500.00", "payout_method": "bkash", "payout_msisdn": "01700000000", "reference": "wd_smart_001", "note": null, "reject_msg": null, "payout_method_trx": "TX123456", "status": 1, "status_name": "completed" }}status uses the same codes as the payment webhook: 1 = completed, 3 = rejected. net_amount is what reached the destination and payout_method_trx is the MFS transaction ID of the payout, when the provider returned one. On a rejection the held total_debit is released back to your available balance and reject_msg carries the reason.
Status reference#
One outcome, two encodings: webhooks send a numeric status plus status_name, while the track-status endpoints send a string. Deposits and withdrawals use the same codes.
| Webhook status | status_name | track-status | Meaning |
|---|---|---|---|
| n/a | n/a | pending | Not final yet. Deposit: awaiting the customer or confirmation. Withdrawal: queued or awaiting admin action, funds held. |
| 1 | completed | completed | Deposit: credited to your balance. Withdrawal: paid out to payout_msisdn. |
| 3 | rejected | rejected | Deposit: wrong or reused transaction ID, an admin rejection, or the payment window passing unpaid. Withdrawal: rejected, cancelled or failed - the hold was released. See reject_msg. |
Rule of thumb: treat status === 1 as the only success and everything else as not-success. That stays correct if a new code is ever added.
Errors#
Errors return the HTTP status below and, where a stable machine-readable value exists, a code field alongside message.
| HTTP | code | When |
|---|---|---|
| 400 | n/a | Malformed body: bad amount format, missing required field, invalid URL, unsupported currency. |
| 401 | n/a | Missing, wrong or revoked API credentials, or a suspended account. |
| 404 | NOT_FOUND | track-status: no payment or withdrawal with that reference on your account. |
| 409 | DUPLICATE_REFERENCE | That reference has already been used. References are unique per merchant, forever. |
| 409 | INSUFFICIENT_BALANCE | Withdrawal only: amount + fee exceeds your available balance. Nothing is held. |
| 422 | DEPOSITS_DISABLED | Deposits are currently switched off for your account. |
| 422 | WITHDRAWALS_DISABLED | Withdrawals are currently switched off for your account. |
| 500 | n/a | Unexpected server error. Don't assume the request failed - see below. |
{ "statusCode": 409, "code": "DUPLICATE_REFERENCE", "message": "A payment with reference \"inv_smart_001\" already exists."}On a timeout or 500, retry with the same reference. If the first attempt did go through you get 409 DUPLICATE_REFERENCE back, which tells you it exists - then read it with track-status. Never retry with a fresh reference: that is how duplicate deposits and double payouts happen.
FAQ#
Which amount field do I credit the customer with?
amount - it is always what was actually paid and credited. original_amount is informational and null in the normal case. For payouts, net_amount is what the destination received, and total_debit (amount + fee) is what left your balance.Do you retry failed callbacks?
track-status - or ask an admin to resend the webhook from the panel.What happens if the customer never pays?
Is there a minimum or maximum amount per transaction?
amount > 0 and two decimal places. Any limits you quote to a platform are commercial: enforce them in your own code before calling.Are amounts always formatted the same way?
"1100.00"). The create response echoes the amount string you sent. Parse as decimal, never as float.How quickly does the deposit webhook arrive?