How it works
BD Secure Pay lets you collect money into your own personal mobile-wallet or bank account and have the payment verified automatically. Your site never handles the customer's wallet PIN or card details - the customer pays on our hosted checkout page and comes back to you.
The flow, end to end| Step | What happens | Who does it |
|---|---|---|
| 1 | Your server calls api/payment/create and receives a payment_url. |
Your server |
| 2 | You redirect the customer's browser to that payment_url. |
Your site |
| 3 | The customer picks a method, pays, and submits the transaction ID. | Customer |
| 4 | The payment is matched against the incoming SMS and marked completed. | BD Secure Pay |
| 5 | The customer is sent back to your success_url (or cancel_url), and your webhook_url is called if you supplied one. |
BD Secure Pay |
| 6 | Your server calls api/payment/verify and only then delivers the order. |
Your server |
success_url by hand and add a query string. Always finish
with step 6 and check that status is
COMPLETED and the amount matches your order.
Authentication
Every request is authenticated with your brand's API key. Find it in Dashboard → Brands - each brand has its own key, so you can run several sites from one account.
Send the key one of two ways| Where | Name | Notes |
|---|---|---|
| HTTP header | API-KEY | Recommended. Keeps the key out of access logs and out of the URL. |
| Request body | api_key | Fallback for platforms that cannot set custom headers. |
| Header | Value |
|---|---|
| Content-Type | application/json |
| API-KEY | Your brand API key |
success_url / cancel_url you
send must belong to one of those domains.
1 Create a payment
| Field | Description | Required | Example |
|---|---|---|---|
| amount | Amount to collect. Numeric, maximum 1,000,000. | Required | 10 or 10.50 |
| success_url | Where the customer returns after paying. | Required | https://yourdomain.com/success.php |
| cancel_url | Where the customer returns if they cancel or the payment fails. | Required | https://yourdomain.com/cancel.php |
| cus_name | Customer's name. Defaults to Default Name if omitted. | Optional | John Doe |
| cus_email | Customer's email. Defaults to default@gmail.com if omitted. | Optional | john@example.com |
| webhook_url | Server-to-server callback, fired once the payment is recorded and again whenever its result changes. Optional - but without it a payment the merchant approves later can never reach your site, because by then the customer's browser is long gone. See Webhook. | Optional | https://yourdomain.com/hook.php |
| metadata | A JSON object echoed back on verify. Use it for your own order ID - a webhook carries none of your query strings, so this is the only place your order number can survive the round trip. | Optional | {"order_id":"1043"} |
metadata must be a JSON object, not a string. Send
"metadata": {"order_id": "1043"} - sending
"metadata": "order 1043" is rejected with
Metadata must be in JSON format.
metadata on the way in and comes back as
metadata on verify. Older sample code that sent
meta_data was wrong - that key is silently ignored.
<?php
$payload = json_encode([
'cus_name' => 'John Doe',
'cus_email' => 'john@example.com',
'amount' => 100,
'success_url' => 'https://yourdomain.com/success.php',
'cancel_url' => 'https://yourdomain.com/cancel.php',
'webhook_url' => 'https://yourdomain.com/hook.php',
'metadata' => ['order_id' => '1043'],
]);
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => 'https://payment.bdhost.org/api/payment/create',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_TIMEOUT => 45,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'API-KEY: YOUR_API_KEY',
],
]);
$response = curl_exec($ch);
if ($response === false) {
exit('Connection failed: ' . curl_error($ch));
}
curl_close($ch);
$result = json_decode($response, true);
if (! empty($result['payment_url'])) {
header('Location: ' . $result['payment_url']);
exit;
}
exit($result['message'] ?? 'Unknown error');
<?php
use GuzzleHttp\Client;
$client = new Client();
$response = $client->post('https://payment.bdhost.org/api/payment/create', [
'headers' => [
'Content-Type' => 'application/json',
'API-KEY' => 'YOUR_API_KEY',
],
'json' => [
'cus_name' => 'John Doe',
'cus_email' => 'john@example.com',
'amount' => 100,
'success_url' => 'https://yourdomain.com/success.php',
'cancel_url' => 'https://yourdomain.com/cancel.php',
'metadata' => ['order_id' => '1043'],
],
'timeout' => 45,
]);
$result = json_decode((string) $response->getBody(), true);
echo $result['payment_url'] ?? $result['message'];
const axios = require('axios');
const { data } = await axios.post(
'https://payment.bdhost.org/api/payment/create',
{
cus_name: 'John Doe',
cus_email: 'john@example.com',
amount: 100,
success_url: 'https://yourdomain.com/success',
cancel_url: 'https://yourdomain.com/cancel',
metadata: { order_id: '1043' },
},
{
headers: {
'Content-Type': 'application/json',
'API-KEY': 'YOUR_API_KEY',
},
timeout: 45000,
}
);
console.log(data.payment_url || data.message);
import requests
payload = {
"cus_name": "John Doe",
"cus_email": "john@example.com",
"amount": 100,
"success_url": "https://yourdomain.com/success",
"cancel_url": "https://yourdomain.com/cancel",
"metadata": {"order_id": "1043"},
}
headers = {
"Content-Type": "application/json",
"API-KEY": "YOUR_API_KEY",
}
res = requests.post(
"https://payment.bdhost.org/api/payment/create",
json=payload,
headers=headers,
timeout=45,
)
data = res.json()
print(data.get("payment_url") or data.get("message"))
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
func main() {
payload, _ := json.Marshal(map[string]interface{}{
"cus_name": "John Doe",
"cus_email": "john@example.com",
"amount": 100,
"success_url": "https://yourdomain.com/success",
"cancel_url": "https://yourdomain.com/cancel",
"metadata": map[string]string{"order_id": "1043"},
})
req, _ := http.NewRequest("POST",
"https://payment.bdhost.org/api/payment/create",
bytes.NewBuffer(payload))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("API-KEY", "YOUR_API_KEY")
client := &http.Client{Timeout: 45 * time.Second}
res, err := client.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}
{
"status": 1,
"message": "Payment Link",
"payment_url": "https://payment.bdhost.org/api/execute/XXXXXXXXXXXX"
}
{
"status": 0,
"message": "Your request with Invalid Parameters."
}
status is the number 1 on
success and 0 on failure - not the booleans
true / false. Check
payment_url is present rather than comparing with
=== true.
2 Redirect the customer
Send the browser to the payment_url you just received. The
customer picks a payment method on our checkout page, pays from their own wallet, and enters
the transaction ID. When they are done we send them back to your
success_url (paid or pending) or your
cancel_url (cancelled or failed).
| Parameter | Description | Example |
|---|---|---|
| transactionId | Our transaction reference. Pass this to the verify endpoint. | OVKPXW165414 |
| paymentAmount | Amount the customer was asked to pay. | 100.000 |
| paymentFee | Gateway fee applied to this transaction, if any. | 0.000 |
| paymentMethod | Method the customer used. | bkash |
| status | completed, pending or failed. |
completed |
success_url can already contain a query string - we
append to it rather than replacing it, so build it with
?order=123 if you need to.
3 Verify the payment
| Field | Description | Required | Example |
|---|---|---|---|
| transaction_id | The transactionId you received on the return URL or webhook. |
Required | OVKPXW165414 |
transaction_id (with an underscore) but you receive
transactionId (camel case) on the return URL. They are the
same value.
<?php
$trxId = $_GET['transactionId'] ?? '';
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => 'https://payment.bdhost.org/api/payment/verify',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode(['transaction_id' => $trxId]),
CURLOPT_TIMEOUT => 45,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'API-KEY: YOUR_API_KEY',
],
]);
$response = curl_exec($ch);
curl_close($ch);
$data = json_decode($response, true);
// deliver the order only when BOTH of these are true
if (($data['status'] ?? '') === 'COMPLETED' && (float) $data['amount'] >= $orderTotal) {
// mark the order as paid
}
<?php
use GuzzleHttp\Client;
$client = new Client();
$response = $client->post('https://payment.bdhost.org/api/payment/verify', [
'headers' => [
'Content-Type' => 'application/json',
'API-KEY' => 'YOUR_API_KEY',
],
'json' => ['transaction_id' => $_GET['transactionId'] ?? ''],
'timeout' => 45,
]);
$data = json_decode((string) $response->getBody(), true);
echo $data['status'];
const axios = require('axios');
const { data } = await axios.post(
'https://payment.bdhost.org/api/payment/verify',
{ transaction_id: req.query.transactionId },
{
headers: {
'Content-Type': 'application/json',
'API-KEY': 'YOUR_API_KEY',
},
timeout: 45000,
}
);
if (data.status === 'COMPLETED' && Number(data.amount) >= orderTotal) {
// mark the order as paid
}
import requests
res = requests.post(
"https://payment.bdhost.org/api/payment/verify",
json={"transaction_id": request.args.get("transactionId")},
headers={
"Content-Type": "application/json",
"API-KEY": "YOUR_API_KEY",
},
timeout=45,
)
data = res.json()
if data.get("status") == "COMPLETED" and float(data["amount"]) >= order_total:
pass # mark the order as paid
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
func main() {
payload, _ := json.Marshal(map[string]string{
"transaction_id": "OVKPXW165414",
})
req, _ := http.NewRequest("POST",
"https://payment.bdhost.org/api/payment/verify",
bytes.NewBuffer(payload))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("API-KEY", "YOUR_API_KEY")
client := &http.Client{Timeout: 45 * time.Second}
res, err := client.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}
{
"cus_name": "John Doe",
"cus_email": "john@example.com",
"amount": "100.000",
"transaction_id": "OVKPXW165414",
"metadata": "{\"order_id\":\"1043\"}",
"payment_method": "bkash",
"status": "COMPLETED"
}
{
"status": 0,
"message": "failed"
}
| status | Meaning | What you should do |
|---|---|---|
| COMPLETED | Money received and matched. | Deliver the order. |
| PENDING | Submitted, but not matched yet - either the wallet SMS has not arrived, or the merchant has to check this one by hand. The second case can take hours. | Keep the order unpaid and tell the customer it is being checked. Do not ask them to pay again. Wait for the webhook rather than polling - it is called the moment the merchant decides. |
| ERROR | Cancelled, failed, or not paid. | Do not deliver. |
| 0 (with "failed") | No such transaction under your API key. | Check the transaction ID and that the key belongs to the same brand. |
metadata comes back as a JSON string, so run it
through json_decode() before reading your keys.
amount is a string too - cast it before comparing.
Webhook
If you send a webhook_url when creating the payment, we
POST to it as soon as the transaction is recorded - even if the
customer closed the browser before being redirected. The body is
form-encoded, not JSON.
It is marked optional in the parameter table, and for a payment that finishes in one go you can live without it. For a payment that does not finish in one go you cannot, and that is worth being blunt about.
Fields posted to your webhook| Field | Example |
|---|---|
| transactionId | OVKPXW165414 |
| paymentAmount | 100.000 |
| paymentFee | 0.000 |
| paymentMethod | bkash |
| status | completed | pending | failed |
A payment does not always land on its answer the first time. When the confirmation SMS cannot be found, or the customer paid by bank transfer, the payment is parked and the merchant checks it by hand - which can be minutes or hours later. Your webhook is called at both moments:
| When | status | What you should do |
|---|---|---|
| The payment is confirmed straight away | completed |
Verify, then deliver the order. Nothing else follows. |
| The payment is parked for the merchant to check | pending |
Verify, then leave the order unpaid and tell the customer it is being checked. Do not treat this as a failure and do not ask them to pay again - the money has already been sent. |
| The merchant approves it | completed |
Verify, then deliver the order. This is the call that arrives with no customer anywhere near a browser. |
| The merchant rejects it | failed |
Cancel the order, or leave it unpaid. Your call. |
api/payment/verify before you deliver anything, and make
your handler idempotent so a repeated call cannot ship the order twice.
Errors
| HTTP | message | Cause / fix |
|---|---|---|
| 400 | Invalid API request | The URL path is not /create or /verify. |
| 404 | Invalid API Request. IP not whitelisted for this brand. | Missing or wrong API key, brand deactivated, or your server IP is not in the brand's IP list. Clear the IP field to allow any address. |
| 400 | Your request with Invalid Parameters. | amount missing, not numeric or above 1,000,000, or success_url / cancel_url missing. |
| 200 | Metadata must be in JSON format. | You sent metadata as a plain string. Send an object. |
| 200 | Required field missing | transaction_id was empty on verify. |
| 200 | failed | No transaction with that ID under this API key. |
/api/*, and turn
Bot Fight Mode off for that path.
Ready-made modules
Drop-in plugins, so you never have to write API code at all.
database.sql with
a single row to import. Those two sit at the top of the zip and are not part of the
platform, so delete them from the server once you are done.
Two settings on every module: Endpoint URL and API Key. The Endpoint URL is this gateway's API address,
https://payment.bdhost.org/, and
the API Key comes from Brands in your dashboard. Nothing is filled in for you, so a
module can never quietly point at the wrong server.
A pending payment is not a failed payment. Every module registers a webhook. If your merchant approves a payment later, that webhook marks the invoice or deposit paid on its own - so never ask the customer to pay a second time.
Perfect SMM Panel users, read this one properly. Version 1.0 of that module sent the API key under the header name
bdsecurepay-API-KEY, and this gateway reads
API-KEY. A key under a name nobody reads is the
same as no key at all, so that module was refused on every request and never
produced a payment page. If Perfect SMM has never worked for you, that was why -
and 1.1.0 fixes it. Its files are blocks to paste into your panel, not files to
upload; the zip contains an INSTALL.txt that says exactly where each block goes.
WordPress Plugin
Accept payments on any WordPress site - stores, memberships or donation pages.
DownloadBlesta
Non-merchant gateway for Blesta. Extracts at your Blesta root and shows up under Payment Gateways.
DownloadFOSSBilling
Payment adapter for FOSSBilling. Invoices settle from the webhook as well as the return.
DownloadPerfex CRM
Online payment gateway for Perfex CRM invoices, with a signed webhook address per payment.
DownloadSmart SMM Panel v4
Add Funds gateway for Smart SMM Panel v4, admin settings screen included.
DownloadPHP Library
A plain PHP class with a working checkout, return page and webhook listener to copy from.
DownloadNeed help?
Stuck on an integration, or seeing a response you cannot explain? Open a ticket from your dashboard with the request you sent and the exact response you got back - that is usually enough for us to spot it straight away.