DGePay PHP Integration Guide: dgepay-php-client SDK
14 min read 2,794 words Tamim Iqbal

DGePay PHP Integration Guide: dgepay-php-client SDK

PHPDGePayPayment GatewayLaravelBangladeshbKash

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_id and client_secret authenticate you, the secret is also the AES key, and client_api_key signs 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 encrypted data parameter. Run str_replace(' ', '+', ...) before decrypting.
  • Only two status codes are documented: 3 for success and 8 for 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.

MethodWhat it doesReturns
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.

KeyRequiredUsed for
client_idYesAuthentication (Basic Auth header and POST body)
client_secretYesAuthentication, and the AES-128-ECB key for requests and callbacks
client_api_keyYesHMAC-SHA256 request signatures
base_urlNoDefaults 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:

StepEndpointBody
Authenticate/payment_gateway/authenticatePlain JSON plus Basic Auth header
Initiate payment/payment_gateway/initiate_paymentAES-encrypted, with Signature and Bearer headers
Check status/payment_gateway/check_transaction_statusAES-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_codeMeaningWhat to do
3Transaction successCheck the amount, then fulfil
8CancelledMark cancelled, let the customer retry
Anything elseNot documented by the SDKLeave 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:

  1. Sort the payload keys alphabetically.
  2. Concatenate each key and value. Nested arrays such as meta_data print the parent key once, then their own sorted children, with no prefix.
  3. Write integers and floats with one decimal place, so 15 becomes 15.0. Strings stay as they are, so a phone number like "+8801712345678" isn't turned into a float.
  4. Write null as the text null and booleans as true or false.
  5. Strip braces, double quotes, colons, spaces and commas.
  6. Take the HMAC-SHA256 with client_api_key and 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.

SymptomLikely cause
InvalidArgumentException on bootAn empty credential, often an uncached or missing .env value
REQUESTED_PARAMETERS_ARE_MISMATCHEDUnencrypted body or a signature mismatch, usually from custom code bypassing the SDK
decryptCallbackData() returns nullPlus signs not restored, or the wrong client_secret
404 from DGePayWrong endpoint paths. The README notes DGePay's JavaScript SDK references paths like /payment/initiate that don't exist in v3.
Nothing in the logsLOG_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

  1. Run PHP 8.3 or newer with curl, json and openssl enabled.
  2. Keep credentials in environment variables or a secrets manager, and out of logs.
  3. Serve redirectUrl over HTTPS.
  4. Insert a pending order with a unique order_id before calling initiatePayment().
  5. Restore plus signs before decryptCallbackData().
  6. Activate only after getTransactionStatus() returns status 3 and the amount matches your record.
  7. Guard the update with status = 'pending' and fulfil only when one row changes.
  8. Schedule a job that re-checks orders left pending.
  9. Set LOG_LEVEL so SDK warnings reach you, and alert on repeated authentication failures.
  10. Pin the package version in composer.lock and 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.

Tamim Iqbal

Tamim Iqbal

IT Manager & AI Developer at REGENERA LUXURY and a certified System Analyst and HRIS Specialist based in Dhaka. He writes about IT management, cloud infrastructure, AI automation and web development from hands-on work. Resume · LinkedIn