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.

bash
Payment channel name : SoftZPay
API domain : https://api.softzpay.online
Documentation : https://panel.softzpay.online/docs
Merchant identifier : <your public key, pk_live_...>
Deposit methods : bKash, Nagad (hosted checkout)
Payout methods : bKash, Nagad (API, no manual step)
Currency : BDT
Per-transaction limit: none enforced by the API (amount > 0)
Checkout link expiry : 30 minutes
Callback source IP : <ask your account manager>
Callback format : POST JSON, HMAC-SHA256 signed
Callback response : any HTTP 2xx, body ignored

Quick start#

  1. 1Generate an API key. The secret is shown once - store it server-side only.
  2. 2POST /api/v1/payment/create-payment and redirect the customer to the payment_url you get back.
  3. 3The customer pays and is returned to your callback_url. Use that only to show a screen - never to credit an order.
  4. 4Credit the order when the webhook arrives with status: 1, or when track-status returns completed. Always fulfil against amount.
  5. 5For payouts, POST /api/v1/withdrawal/create-withdrawal and wait for its webhook - same status: 1 rule.

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.

bash
X-Authorization: pk_live_xxxxxxxxxxxxxxxxxxxxxxxx
X-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.

POST/api/v1/payment/create-payment
FieldTypeRequiredDescription
amountstringrequiredDecimal amount, e.g. "1100" or "1100.50". Max 2 decimal places, must be > 0.
referencestringrequiredYour unique order ID. Must be unique per merchant - reused values return 409.
callback_urlstringrequiredWhere the customer's browser is redirected after the payment attempt.
webhook_urlstringoptionalServer-to-server URL that receives the final status. See Payment webhook below.
cust_namestringrequiredCustomer's name.
cust_phonestringoptionalCustomer's phone number.
currencystringoptional"BDT" - the only accepted value, case-insensitive. Defaults to BDT when omitted.
deposit_methodstringoptional"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.
typestringoptionalProvider 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

bash
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

json
{
"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

Enter your public and secret key above to send a live request.

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:

bash
https://example.com/callback?payment=completed&reference=inv_smart_001&request_id=aWJ2X3Rva2VuX2V4YW1wbGU

payment 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.

GET/api/v1/payment/track-status/:reference
bash
curl "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)"
json
{
"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.

json
{
"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"
}
}
FieldWhat it means
referenceYour order ID - use it as the idempotency key on your side.
amountWhat the customer actually paid and what was credited to your balance. Always fulfil against this. Always 2 decimal places.
original_amountNull unless the amount was adjusted; then it holds the amount you originally requested. Informational only.
amount_adjustedTrue when amount differs from what you requested (see below). False in the normal case.
status1 = completed, 3 = rejected. Treat only 1 as success.
status_nameHuman-readable form of status - "completed" or "rejected".
reject_msgReason on a rejection, when one was recorded. Null on success.
payment_method_trxThe 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:

json
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.

POST/api/v1/withdrawal/create-withdrawal
FieldTypeRequiredDescription
amountstringrequiredDecimal amount to pay out, e.g. "500" or "500.50". Delivered in full to the destination; the fee is added on top.
referencestringrequiredYour unique withdrawal ID. Must be unique per merchant - reused values return 409.
currencystringrequired"BDT" - the only accepted value, case-insensitive.
payout_methodstringrequired"bkash" | "nagad" (case-insensitive) - where the payout is sent. Reported back in lowercase.
payout_msisdnstringrequiredDestination 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_urlstringoptionalServer-to-server URL that receives the final status. See Withdrawal webhook below.

Example request

bash
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

json
{
"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 fieldWhat it means
amountWhat you asked us to pay out.
feeWithdrawal fee at your configured rate, charged on top of amount.
net_amountWhat the destination wallet receives. Always equal to amount under this fee model - use it as the payout figure.
total_debitWhat 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.

Enter your public and secret key above to send a live request.

Track withdrawal status#

Poll this endpoint to check a withdrawal's current status using your own reference.

GET/api/v1/withdrawal/track-status/:reference
bash
curl "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)"
json
{
"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.

json
{
"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 statusstatus_nametrack-statusMeaning
n/an/apendingNot final yet. Deposit: awaiting the customer or confirmation. Withdrawal: queued or awaiting admin action, funds held.
1completedcompletedDeposit: credited to your balance. Withdrawal: paid out to payout_msisdn.
3rejectedrejectedDeposit: 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.

HTTPcodeWhen
400n/aMalformed body: bad amount format, missing required field, invalid URL, unsupported currency.
401n/aMissing, wrong or revoked API credentials, or a suspended account.
404NOT_FOUNDtrack-status: no payment or withdrawal with that reference on your account.
409DUPLICATE_REFERENCEThat reference has already been used. References are unique per merchant, forever.
409INSUFFICIENT_BALANCEWithdrawal only: amount + fee exceeds your available balance. Nothing is held.
422DEPOSITS_DISABLEDDeposits are currently switched off for your account.
422WITHDRAWALS_DISABLEDWithdrawals are currently switched off for your account.
500n/aUnexpected server error. Don't assume the request failed - see below.
json
{
"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?

For deposits, 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?

No. One attempt with a 5 second timeout. If it fails, poll track-status - or ask an admin to resend the webhook from the panel.

What happens if the customer never pays?

The checkout link expires (30 minutes by default) and the payment resolves as rejected, firing the same rejection webhook. There is no separate expired status to handle.

Is there a minimum or maximum amount per transaction?

The API enforces none beyond 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?

In callbacks and track-status, yes: a decimal string with exactly two places ("1100.00"). The create response echoes the amount string you sent. Parse as decimal, never as float.

How quickly does the deposit webhook arrive?

Usually within seconds of the customer's payment being matched automatically. If the payment needs manual admin confirmation it fires when that happens instead, so treat the wait as open-ended and never block a checkout flow on it.