DGePay PHP Integration Guide: dgepay-php-client SDK
To accept DGePay payments in PHP, install tamimiqbal/dgepay-php with Composer, call initiatePayment(), redirect the customer to the returned URL, then decrypt the callback and confirm the result with getTransactionStatus() before you mark anything as paid. The SDK takes care of the parts that usually break a DGePay integration: AES-128-ECB request encryption, the HMAC-SHA256 signature format and the base64 callback that PHP quietly corrupts.
I wrote dgepay-php-client after integrating DGePay into a live Laravel app and hitting every undocumented quirk along the way. It's MIT licensed, and version 1.0.0 went up on Packagist in March 2026. It's a community project. It isn't affiliated with or endorsed by DGePay or Bangladesh Bank.
This guide sticks to what the package's source and README actually contain. Where the SDK doesn't cover something, such as a sandbox URL or a server-to-server notification, I say so and tell you what to do instead.
Key takeaways
- Three credentials, three jobs:
client_idandclient_secretauthenticate you, the secret is also the AES key, andclient_api_keysigns every request. - Never activate an order from the callback alone. Use it only to find the order ID, then trust
getTransactionStatus()and check the amount against your own record. - Restore the plus signs. PHP turns
+into spaces in$_GET, which breaks the encrypteddataparameter. Runstr_replace(' ', '+', ...)before decrypting. - Only two status codes are documented:
3for success and8for cancelled. Treat anything else as unpaid and check again later. - No sandbox URL ships with the SDK. The base URL is configurable, so ask DGePay for test credentials and a test endpoint.
What the SDK covers, and what it leaves to you
It's a single class, DgePay\DgePay, plus a Laravel service provider and facade. It talks to three DGePay API v3 endpoints over cURL, with a 30-second timeout and TLS peer verification switched on.
| Method | What it does | Returns |
|---|---|---|
authenticate() | Gets a JWT access token. Called for you by the two methods below. | success, access_token or message |
initiatePayment(array) | Signs, encrypts and sends a new payment request. | success, payment_url, transaction_id or message |
getTransactionStatus(string) | Asks DGePay for the current state of an order. | success, data or message |
decryptCallbackData(string) | Decrypts the ?data= value from the redirect. | Array, or null on failure |
parseCallbackResult(array) | Normalises callback fields into is_success, is_cancelled, unique_txn_id and so on. | Array |
generateSignature(), encryptPayload(), decryptPayload() | The low-level crypto, exposed for debugging. | Strings |
generateTransactionId(), isSuccessStatus(), isCancelledStatus() | Static helpers. | String or bool |
What it doesn't do: refunds, token caching (every call authenticates again, so initiating a payment is two HTTP requests), retries, or a handler for server-to-server payment notifications. Your order table, your idempotency and your reconciliation are yours to build. If you're scoping that work for a client, the requirement-gathering steps in system analysis techniques for modern IT professionals fit a payment project well.
Requirements and installation
The composer.json asks for PHP 8.1 or later plus ext-curl, ext-json and ext-openssl. That's the floor, not a recommendation. PHP 8.1 reached end of life on 31 December 2025, and 8.2 loses security support on 31 December 2026 according to php.net's supported versions page. For a server that handles money, run 8.3 or newer.
composer require tamimiqbal/dgepay-php
Without Composer, clone the repository and require the class directly. It has no other PHP dependencies, so that works, though you lose version pinning and updates.
require_once __DIR__ . '/path/to/dgepay-php-client/src/DgePay.php';
Configuration and secrets handling
Configuration is one array with four keys. If any of the first three is empty, it throws an InvalidArgumentException, which is the only exception the SDK throws on purpose.
| Key | Required | Used for |
|---|---|---|
client_id | Yes | Authentication (Basic Auth header and POST body) |
client_secret | Yes | Authentication, and the AES-128-ECB key for requests and callbacks |
client_api_key | Yes | HMAC-SHA256 request signatures |
base_url | No | Defaults to https://apiv2.dgepay.net/dipon/v3 |
use DgePay\DgePay;
$dgepay = new DgePay([
'client_id' => getenv('DGEPAY_CLIENT_ID'),
'client_secret' => getenv('DGEPAY_CLIENT_SECRET'),
'client_api_key' => getenv('DGEPAY_CLIENT_API_KEY'),
'base_url' => getenv('DGEPAY_BASE_URL') ?: 'https://apiv2.dgepay.net/dipon/v3',
]);
Because the client secret is also the encryption key, a leaked secret doesn't just let someone call the API as you. It lets them decrypt your callbacks and forge encrypted payloads. Keep all three values in environment variables or a secrets manager, never in Git, and rotate them through DGePay if they ever show up in a log, a screenshot or a support ticket.
The payment flow, step by step
Every payment follows the same path: authenticate, initiate, redirect, callback, verify, fulfil. These are the endpoints the SDK calls:
| Step | Endpoint | Body |
|---|---|---|
| Authenticate | /payment_gateway/authenticate | Plain JSON plus Basic Auth header |
| Initiate payment | /payment_gateway/initiate_payment | AES-encrypted, with Signature and Bearer headers |
| Check status | /payment_gateway/check_transaction_status | AES-encrypted, with Signature and Bearer headers |
1. Save the order, then initiate
Write a pending row to your database before you call DGePay. That row is what you verify against later, and it's where the expected amount lives.
$orderId = DgePay::generateTransactionId(); // "DG" + YmdHis + 3 random digits
// INSERT INTO payments (order_id, amount, status) VALUES (?, 2499.00, 'pending')
$result = $dgepay->initiatePayment([
'amount' => 2499.00,
'description' => 'Pro Plan - 1 Year',
'orderId' => $orderId,
'redirectUrl' => 'https://yoursite.com/payment/callback',
// Optional
'unique_user_reference' => 'user_123',
'meta_data' => [
'custom_field_1' => 'pro',
],
]);
if ($result['success']) {
header('Location: ' . $result['payment_url']);
exit;
}
echo 'Payment initiation failed: ' . $result['message'];
amount, orderId and redirectUrl are required. The optional keys are description, payment_method (to force bKash, Nagad and so on), customer_token, payee_information, unique_user_reference and meta_data, which takes up to three custom fields named custom_field_1 to custom_field_3.
generateTransactionId() puts three random digits on a per-second timestamp. On a busy checkout, add a unique index on order_id so a rare collision fails loudly instead of overwriting an order.
2. Decrypt the callback, but don't trust it
When the customer finishes, DGePay sends them back to your redirectUrl with ?data= holding AES-encrypted base64. Base64 uses +, and the PHP manual notes that plus signs decode to spaces and that $_GET is already decoded. So by the time you read the value, it's broken. Put the plus signs back first:
$params = $_GET;
if (isset($_GET['data'])) {
$raw = str_replace(' ', '+', $_GET['data']);
$decrypted = $dgepay->decryptCallbackData($raw);
$params = $decrypted ?? [];
}
$callback = $dgepay->parseCallbackResult($params);
$orderId = $callback['unique_txn_id'];
Here's why the callback is only a hint. parseCallbackResult() also accepts plain query parameters, and it reports success for status=success as long as unique_txn_id is set. Anyone can type that into a browser. The redirect also travels through the customer's browser, so it can be replayed or never arrive. Use it to learn which order the customer is returning for, and nothing more.
3. Verify with the status API and fulfil once
$check = $dgepay->getTransactionStatus($orderId);
$data = $check['data'] ?? [];
$code = (string) ($data['status_code'] ?? '');
$paid = $check['success']
&& DgePay::isSuccessStatus($code)
&& abs((float) ($data['amount'] ?? 0) - (float) $expectedAmount) < 0.01;
if ($paid) {
// UPDATE payments SET status = 'completed', trx_id = ?
// WHERE order_id = ? AND status = 'pending'
// Fulfil only if exactly one row changed.
} elseif (DgePay::isCancelledStatus($code)) {
// UPDATE payments SET status = 'cancelled' WHERE order_id = ? AND status = 'pending'
}
Three details matter here. $check['success'] only means the HTTP call returned 2xx. It says nothing about whether the customer paid, so you always read status_code too. The (string) cast is there because the callback sends status_code as an integer while isSuccessStatus() takes a string, and under declare(strict_types=1) an integer would throw a TypeError. And the AND status = 'pending' guard stops a refreshed callback page from fulfilling the same order twice.
status_code | Meaning | What to do |
|---|---|---|
3 | Transaction success | Check the amount, then fulfil |
8 | Cancelled | Mark cancelled, let the customer retry |
| Anything else | Not documented by the SDK | Leave pending and check again later |
What about IPN?
You get no server-to-server notification (IPN or webhook) handler in the SDK, and its documented flow ends with the browser redirect. If a customer pays and then closes the tab, your callback never runs. Cover that with a scheduled job, cron or Laravel's scheduler, that picks up orders still pending after a few minutes and calls getTransactionStatus() on each. If your DGePay merchant documentation does describe a notification endpoint, treat it exactly like the redirect: decrypt it, then verify through the status API.
How the SDK handles AES encryption and signatures
DGePay rejects plain JSON on the initiate and status endpoints with REQUESTED_PARAMETERS_ARE_MISMATCHED. The SDK's encryption is short enough to show in full:
// Inside DgePay::encryptPayload()
$json = json_encode($data);
return openssl_encrypt($json, 'AES-128-ECB', $this->clientSecret, 0);
With options set to 0, openssl_encrypt returns base64. The PHP manual also says there's no key derivation: a key shorter than the cipher expects is padded with NUL bytes, and a longer one is silently truncated. For AES-128, only the first 16 bytes of your client secret are used. That's the behaviour DGePay's side expects, so leave it alone.
Decrypting callbacks tries the base64 string first, then falls back to decoding it manually and decrypting the raw bytes. If both fail, you get null and a warning in your logger with the data length and the first 80 characters.
Signatures go in a Signature header. The SDK builds it like this:
- Sort the payload keys alphabetically.
- Concatenate each key and value. Nested arrays such as
meta_dataprint the parent key once, then their own sorted children, with no prefix. - Write integers and floats with one decimal place, so
15becomes15.0. Strings stay as they are, so a phone number like"+8801712345678"isn't turned into a float. - Write
nullas the textnulland booleans astrueorfalse. - Strip braces, double quotes, colons, spaces and commas.
- Take the HMAC-SHA256 with
client_api_keyand base64 the raw result.
Step 3 is the one that bites people who write their own client. The SDK uses is_int() and is_float() rather than is_numeric(), so the type you pass matters. The SDK casts amount to float for you. Everything else is signed with the type you give it.
Laravel integration
Laravel setup is short. The package declares its provider and facade under extra.laravel in composer.json, so package discovery registers both when you install it. Publish the config and set your environment:
php artisan vendor:publish --tag=dgepay-config
DGEPAY_CLIENT_ID=your_client_id
DGEPAY_CLIENT_SECRET=your_client_secret
DGEPAY_CLIENT_API_KEY=your_api_key
DGEPAY_BASE_URL=https://apiv2.dgepay.net/dipon/v3
Its service provider binds DgePay as a singleton and wires the SDK's logger to Laravel's Log facade. Inject it into a controller. This callback follows the verify-first rule from above, assuming a payments table with order_id, amount, status, trx_id and gateway columns:
use DgePay\DgePay;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
public function callback(Request $request, DgePay $dgepay)
{
$params = $request->query();
if ($request->has('data')) {
$raw = str_replace(' ', '+', (string) $request->query('data'));
$params = $dgepay->decryptCallbackData($raw) ?? [];
}
$orderId = $dgepay->parseCallbackResult($params)['unique_txn_id'];
$payment = DB::table('payments')->where('order_id', $orderId)->first();
if (! $payment) {
return redirect()->route('payment.plans')->with('error', 'Payment not found.');
}
if ($payment->status === 'completed') {
return redirect()->route('payment.success');
}
$check = $dgepay->getTransactionStatus($orderId);
$data = $check['data'] ?? [];
$code = (string) ($data['status_code'] ?? '');
$paid = $check['success']
&& DgePay::isSuccessStatus($code)
&& abs((float) ($data['amount'] ?? 0) - (float) $payment->amount) < 0.01;
if (! $paid) {
if (DgePay::isCancelledStatus($code)) {
DB::table('payments')->where('order_id', $orderId)
->where('status', 'pending')
->update(['status' => 'cancelled', 'updated_at' => now()]);
}
Log::warning('DGePay payment not verified', ['order_id' => $orderId, 'data' => $data]);
return redirect()->route('payment.plans')->with('error', 'Payment was not completed.');
}
$updated = DB::table('payments')->where('order_id', $orderId)
->where('status', 'pending')
->update([
'status' => 'completed',
'trx_id' => $data['txn_number'] ?? null,
'gateway' => $data['payment_method'] ?? null,
'updated_at' => now(),
]);
if ($updated === 1) {
// First time we've seen this payment: grant access, send the receipt.
}
return redirect()->route('payment.success');
}
One warning about the repository's own examples/laravel-controller.php: if the status check fails, it falls back to the callback data and still marks the payment completed. That's fine for a demo. Don't copy it into production. Use the version above, where a failed check means no activation.
Once you run php artisan config:cache, the Laravel docs warn that the .env file isn't loaded, so env() outside config files only sees system-level variables. Read credentials through config('dgepay.client_id') if you need them anywhere else. If you're wiring payment events into other systems after fulfilment, automating business workflows covers the patterns I use.
Error handling
Apart from the constructor, the SDK doesn't throw. Network failures, cURL errors and non-2xx responses all come back as ['success' => false, 'message' => ...], so check success every time. The message comes from DGePay's error[0] or message field when there is one, or a generic fallback such as "Could not connect to DGePay: ..." when the request never completed.
| Symptom | Likely cause |
|---|---|
InvalidArgumentException on boot | An empty credential, often an uncached or missing .env value |
REQUESTED_PARAMETERS_ARE_MISMATCHED | Unencrypted body or a signature mismatch, usually from custom code bypassing the SDK |
decryptCallbackData() returns null | Plus signs not restored, or the wrong client_secret |
| 404 from DGePay | Wrong endpoint paths. The README notes DGePay's JavaScript SDK references paths like /payment/initiate that don't exist in v3. |
| Nothing in the logs | LOG_LEVEL=warning hides info entries. The SDK logs failures at warning and error. |
Outside Laravel, attach a logger with setLogger(). The callback receives the level, the message and a context array, and it's the fastest way to see DGePay's raw error body.
$dgepay->setLogger(function (string $level, string $message, array $context) {
error_log("[{$level}] {$message}: " . json_encode($context));
});
Testing and sandbox
A PHPUnit 10 suite ships in the repository, covering signature determinism, float formatting, numeric strings, callback parsing, encryption round trips and ID generation. Run it after cloning:
composer install
./vendor/bin/phpunit
For your own tests, the class isn't final, so you can mock DgePay with PHPUnit's createMock() or bind a fake in Laravel's container and drive your controller through success, cancelled, unknown and failed-check responses without touching the network. Test the forged case too: a request to your callback with ?unique_txn_id=...&status=success must not complete an order.
You won't find a sandbox URL in the SDK. Its config file just says to change base_url if DGePay gives you one. Ask your DGePay account contact for test credentials and a test endpoint. If they can't provide one, run end-to-end tests on a staging domain with the smallest amount your merchant account allows.
Production checklist
- Run PHP 8.3 or newer with
curl,jsonandopensslenabled. - Keep credentials in environment variables or a secrets manager, and out of logs.
- Serve
redirectUrlover HTTPS. - Insert a pending order with a unique
order_idbefore callinginitiatePayment(). - Restore plus signs before
decryptCallbackData(). - Activate only after
getTransactionStatus()returns status3and the amount matches your record. - Guard the update with
status = 'pending'and fulfil only when one row changes. - Schedule a job that re-checks orders left pending.
- Set
LOG_LEVELso SDK warnings reach you, and alert on repeated authentication failures. - Pin the package version in
composer.lockand read the diff before upgrading.
Security notes
- AES-128-ECB is weak by design. ECB uses no IV, so identical plaintext blocks give identical ciphertext. DGePay requires it, which is why the SDK uses it. Don't reuse the pattern in your own systems.
- The callback isn't authenticated on its own. Decryption proves someone had the key. It doesn't prove the payment settled. The status API does.
- Don't log full payloads in production. Callback data can include customer tokens and metadata such as email addresses.
- Treat the client secret as your most sensitive value. It authenticates you and decrypts your traffic.
For the wider governance side of running payment systems in a Bangladeshi company, see IT management best practices in Bangladesh. If you're deciding whether payments belong in a custom build at all, custom CMS vs WordPress architecture weighs that trade-off.
FAQ
Is dgepay-php-client an official DGePay SDK?
No. It's an independent, MIT-licensed package built from a production integration. It isn't affiliated with or endorsed by DGePay or Bangladesh Bank, so check anything critical against the API documentation DGePay gives you as a merchant.
Which payment methods does it support?
Whatever DGePay offers your merchant account. The SDK sends the customer to DGePay's hosted checkout, and the README names bKash and Nagad among the options. You can pass payment_method to force one.
Why does decryptCallbackData() return null?
Nearly always because the plus signs in the base64 string became spaces. Run str_replace(' ', '+', $_GET['data']) first. If it still fails, check that client_secret matches the account that created the payment.
Do I need to call authenticate() myself?
No. initiatePayment() and getTransactionStatus() each authenticate before their request. Call it directly only to test your credentials.
Does it work without Laravel?
Yes. The core class only needs PHP and the cURL, JSON and OpenSSL extensions. The Laravel provider and facade are optional extras.
I built and maintain this SDK. If you're stuck on a DGePay integration or want a review before go-live, you can get in touch here.