Developer documentation

Start taking payments with BD Secure Pay in two API calls.

Create a payment link, send your customer to it, then verify the transaction server side. That is the whole integration - two POST endpoints, JSON in, JSON out, no SDK to install.

2REST endpoints
5Language examples
21Ready-made modules
JSONRequest & response

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
Never trust the redirect alone. Anyone can open your 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.
There is no separate sandbox host. Test with a small real amount on your live key, or create a second brand and use its key for testing.

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.
Required headers
Header Value
Content-Type application/json
API-KEY Your brand API key
IP whitelist. If you filled the IP field on your brand, requests are only accepted from those addresses. Leave it empty to allow any IP. Behind Cloudflare, make sure you whitelist your server's outgoing IP, not a visitor's.
Domain lock. If you filled the Domains field on your brand, the success_url / cancel_url you send must belong to one of those domains.

1 Create a payment

POST https://payment.bdhost.org/api/payment/create returns a payment link
Request body
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.
The field is named 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.
Example request
<?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))
}
200 - success
{
  "status": 1,
  "message": "Payment Link",
  "payment_url": "https://payment.bdhost.org/api/execute/XXXXXXXXXXXX"
}
400 - bad parameters
{
  "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).

Query string added to your return URL
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
Your 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

POST https://payment.bdhost.org/api/payment/verify confirms a transaction
Request body
Field Description Required Example
transaction_id The transactionId you received on the return URL or webhook. Required OVKPXW165414
You send transaction_id (with an underscore) but you receive transactionId (camel case) on the return URL. They are the same value.
Example request
<?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))
}
200 - transaction found
{
  "cus_name": "John Doe",
  "cus_email": "john@example.com",
  "amount": "100.000",
  "transaction_id": "OVKPXW165414",
  "metadata": "{\"order_id\":\"1043\"}",
  "payment_method": "bkash",
  "status": "COMPLETED"
}
200 - unknown transaction
{
  "status": 0,
  "message": "failed"
}
Status values
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
Expect it more than once

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.
This is the whole reason to send a webhook_url. The return URL only ever runs while the customer is still sitting there. A payment approved an hour later has no browser left to come back with, so a site that relies on the return URL alone will keep that order unpaid forever even though the merchant has approved it and the money is in. If you only wire up one thing from this page, wire up this.
<?php
// hook.php - read the webhook, then confirm it against the verify endpoint
$trxId = $_POST['transactionId'] ?? '';
if ($trxId === '') {
    http_response_code(400);
    exit;
}

// never trust the webhook body on its own - re-verify
$data = verifyPayment($trxId); // your own wrapper around api/payment/verify

// metadata comes back as a JSON string, so decode it to find your own order
$meta    = json_decode($data['metadata'] ?? '', true) ?: [];
$orderId = $meta['order_id'] ?? '';

switch ($data['status'] ?? '') {
    case 'COMPLETED':
        // mark the order as paid, idempotently: this can arrive twice
        break;
    case 'PENDING':
        // the merchant has not decided yet. leave the order alone and wait
        // for the next call - do not cancel it, do not ask for payment again
        break;
    default:
        // rejected or never paid
        break;
}

http_response_code(200);
echo 'OK';
The webhook is not signed. Treat it purely as a nudge - always 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.
Behind Cloudflare? If your API calls suddenly return HTML instead of JSON, a Cloudflare rule is challenging your own server. In the Cloudflare dashboard add a WAF custom rule with action Skip for /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.

Every zip below extracts at the root of the platform it is for. Upload the zip to the folder that holds that project's own files, unzip it there, and each file lands where it belongs - no dragging folders around. The zip also carries a README, and where the platform needs one, a 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.
WHMCS, WordPress and Perfect SMM: download these again. All three were updated to 1.1.0 and now register a webhook, so a pending payment the merchant approves later marks the invoice or the order paid on its own. The older versions only listened to the customer's browser coming back, which left those orders unpaid. Upgrading WHMCS and WordPress is just overwriting the files - no setting changed name, and nothing needs reconfiguring.

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.

Download

WHMCS Module

Take payments, manage invoices and track transactions inside WHMCS.

Download

SMM Panel Gateway

Plug straight into your SMM panel checkout flow.

Download

Perfect SMM Panel Gateway

The same checkout, built for Perfect SMM Panel.

Download

Android App

Track and verify every live transaction from your Android device.

Download App

Sketchware SWB

A ready Sketchware project so you can build your own app on top of the API.

Download

Blesta

Non-merchant gateway for Blesta. Extracts at your Blesta root and shows up under Payment Gateways.

Download

WISECP

Payment module for WISECP, with English and Turkish language files included.

Download

FOSSBilling

Payment adapter for FOSSBilling. Invoices settle from the webhook as well as the return.

Download

BoxBilling

Payment adapter for BoxBilling and its forks.

Download

Clientexec

Gateway plugin for Clientexec, with its own callback entry point.

Download

Perfex CRM

Online payment gateway for Perfex CRM invoices, with a signed webhook address per payment.

Download

Onest LMS

Payment method for Onest LMS course checkout.

Download

Easy Digital Downloads

WordPress plugin that adds this gateway to EDD checkout.

Download

Smart SMM Panel v4

Add Funds gateway for Smart SMM Panel v4, admin settings screen included.

Download

SMM Matrix

Deposit gateway for SMM Matrix panels.

Download

SMMCrowd

Deposit gateway for SMMCrowd panels.

Download

PHP Library

A plain PHP class with a working checkout, return page and webhook listener to copy from.

Download

CodeIgniter 3 SDK

Library, config and controller for CodeIgniter 3 projects.

Download

Laravel SDK

Service class, controller, config and routes for Laravel projects.

Download

Android SDK

Java source for taking a payment inside your own Android app.

Download

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