# BDPayGate: full documentation Source: https://bdpaygate.github.io/ · Package: byteminus/laravel-bdpaygate · Repository: https://github.com/byteminus/laravel-bdpaygate # BDPayGate: Bangladesh payment gateways for Laravel Website: **https://bdpaygate.github.io** Every Bangladeshi payment gateway behind one Laravel API: **bKash, Nagad, SSLCommerz, Upay, ShurjoPay, aamarPay, PortPos, PayStation, UddoktaPay, Moneybag, EPS and DGePay**. Rocket, OK Wallet and IBBL/CellFin are covered through SSLCommerz, ShurjoPay and aamarPay. ```php use ByteMinus\BdPayGate\Facades\BdPayGate; use ByteMinus\BdPayGate\Data\Customer; $payment = BdPayGate::driver('bkash')->charge( amount: 1500, reference: 'ORD-1001', customer: new Customer(name: 'Rahim Uddin', phone: '01711000000', email: 'rahim@example.com'), description: 'Order #1001', ); return redirect($payment->checkout_url); ``` That is the whole integration. The package owns the callback URLs, confirms every payment with the gateway's own API before trusting it, stores it, fires an event once, and sends the customer back to your app. ## Why this one - **No callback is trusted.** Redirects, IPNs and webhooks are claims. Every gateway confirms the payment server-to-server and checks the amount and currency before anything changes. A forged "failed" or "cancelled" can't stick either: if the gateway later confirms the money moved, the payment completes. - **Exactly once.** Status changes are atomic, so an IPN and a redirect arriving together can't both fire `PaymentCompleted`. - **A timeout is not a failure.** When a gateway doesn't answer, the payment stays pending and `bdpaygate:sync` settles it later. Customers who paid are never marked failed. - **Retries are safe.** Each retry is a new attempt with a fresh gateway id; earlier attempts are kept, and if a customer pays two of them you get a `PaymentDuplicated` event instead of a second fulfilment. - **One conformance suite for every gateway.** Forged callbacks, amount mismatches, replays, races, timeouts, late successes, malformed input, sync and refunds are tested the same way for all of them. - Laravel 11, 12 and 13 · PHP 8.2 to 8.5 · Pest, PHPStan, Pint. ## Gateways | Gateway | Status | Real sandbox run | Refunds | Sync | Docs | |---|---|---|---|---|---| | SSLCommerz (cards, banks, wallets incl. Rocket, OK Wallet, CellFin) | Stable | ✅ paid, risk-flagged, partial refund, sync | Full and partial | Yes | [sslcommerz](https://bdpaygate.github.io/docs/sslcommerz.md) | | ShurjoPay | Stable | ✅ paid, declined | Merchant panel | Yes | [shurjopay](https://bdpaygate.github.io/docs/shurjopay.md) | | aamarPay | Stable | ✅ paid, declined | Merchant panel | Yes | [aamarpay](https://bdpaygate.github.io/docs/aamarpay.md) | | UddoktaPay (self-hosted, read the risk note) | Stable | ✅ paid, forged invoice rejected | Merchant panel | Yes | [uddoktapay](https://bdpaygate.github.io/docs/uddoktapay.md) | | bKash (Tokenized V2, v1.2 supported) | Stable | ✅ paid (V2), two partial refunds, refund status, replayed and forged callbacks rejected; v1.2 create verified | Full and partial | Yes | [bkash](https://bdpaygate.github.io/docs/bkash.md) | | PayStation | Stable | ✅ paid via bKash, settled through the status API | Merchant panel | Yes | [paystation](https://bdpaygate.github.io/docs/paystation.md) | | Nagad | Beta | Needs merchant sandbox keys | Merchant panel | Yes | [nagad](https://bdpaygate.github.io/docs/nagad.md) | | Upay | Beta | Needs merchant sandbox keys | Full | Yes | [upay](https://bdpaygate.github.io/docs/upay.md) | | PortPos (formerly PortWallet) | Beta | Needs merchant sandbox keys | Full and partial | Yes | [portpos](https://bdpaygate.github.io/docs/portpos.md) | | Moneybag | Stable | ✅ paid via bKash, simulated failure | Merchant panel | Via transaction id | [moneybag](https://bdpaygate.github.io/docs/moneybag.md) | | EPS | Experimental | Needs docs and keys | Merchant panel | Yes | [eps](https://bdpaygate.github.io/docs/eps.md) | | DGePay | Experimental | Needs docs and keys | Merchant panel | Yes | [dgepay](https://bdpaygate.github.io/docs/dgepay.md) | "Real sandbox run" means a payment was made in the gateway's own sandbox through a Laravel 13 app and settled by this package (2026-10-15). "Merchant panel" means the gateway publishes no refund API; refund from its dashboard. **Stable**: built from the gateway's public documentation, request and response shapes checked against it or its sandbox. **Beta**: built from public information; not yet run against a merchant sandbox. **Experimental**: the gateway's docs are only available on request; built from its published SDKs. Every gateway page lists what is not yet verified. Test with your own sandbox account before going live. ## Install ```bash composer require byteminus/laravel-bdpaygate php artisan vendor:publish --tag=bdpaygate-config php artisan migrate ``` Add credentials for the gateways you use to `.env` (each gateway page lists its variables), and where to send customers afterwards: ```dotenv BDPAYGATE_GATEWAY=bkash BDPAYGATE_REDIRECT_SUCCESS=/checkout/thanks BDPAYGATE_REDIRECT_FAILED=/checkout/failed BDPAYGATE_REDIRECT_CANCELLED=/cart BDPAYGATE_REDIRECT_PENDING=/checkout/processing ``` Redirect values can be a URL or a route name. The customer arrives with `?payment={id}`. Schedule the sync command so payments whose callback never arrived get settled: ```php // routes/console.php Schedule::command('bdpaygate:sync')->everyTenMinutes(); ``` ## The things you will actually do ### Take a payment ```php $payment = BdPayGate::charge(1500, 'ORD-1001', $customer, 'Order #1001', ['order_id' => 1001]); return redirect($payment->checkout_url); ``` `BdPayGate::charge()` uses the default gateway; `BdPayGate::driver('nagad')` picks one. `reference` is your order id. Calling `charge()` again with the same reference (the customer retried) creates a new attempt. Anything in `meta` is stored with the payment. ### React when it is paid ```php use ByteMinus\BdPayGate\Events\PaymentCompleted; Event::listen(PaymentCompleted::class, function (PaymentCompleted $event) { Order::find($event->payment->meta['order_id'])->markPaid($event->payment->gateway_transaction_id); }); ``` Do your business logic in listeners, never on the thank-you page: the customer may close the browser before reaching it, and the IPN or `bdpaygate:sync` will still complete the payment. | Event | When | |---|---| | `PaymentCompleted` | The gateway confirmed the payment. Fires once per reference. | | `PaymentFailed` / `PaymentCancelled` | The gateway confirmed it did not go through. | | `PaymentDuplicated` | Another attempt for an already-paid reference was also paid. Refund it. | | `RefundProcessed` | A refund was accepted (`$event->result->pending` if the gateway settles it later). | If `$payment->requires_review` is true, the gateway flagged the payment as risky (e.g. SSLCommerz `risk_level=1`): hold delivery until you have checked it. ### Show the result ```php $payment = \ByteMinus\BdPayGate\Models\Payment::findOrFail(request('payment')); $payment->isPaid(); $payment->gateway; // "bkash" $payment->gateway_transaction_id; // the gateway's transaction id $payment->failure_reason; // e.g. "2023: Insufficient Balance" ``` ### Refund ```php $result = BdPayGate::refund($payment); // everything left $result = BdPayGate::refund($payment, 500, 'Damaged item'); // partial, where supported $result->accepted; // the gateway took it $result->pending; // it settles later ``` Gateways without a refund API throw `UnsupportedOperationException`; refund those from the merchant panel. Check ahead of time with `BdPayGate::for($payment) instanceof SupportsRefunds`. ### Test your app without the network ```php $fake = BdPayGate::fake(); // becomes the default gateway $payment = BdPayGate::charge(300, 'ORD-1', $customer); $this->get($payment->checkout_url); // completes it, like a real redirect $fake->fail($payment); // or drive it from code $fake->complete($payment); BdPayGate::refund($payment, 100); ``` ## AI assistants The package ships guidance for AI coding assistants. With [Laravel Boost](https://laravel.com/docs/boost), run `php artisan boost:install` after installing BDPayGate and your assistant gets the package's rules (fulfil orders in `PaymentCompleted`, never on the redirect page), the API, and an integration skill with every gateway's `.env` keys and troubleshooting. For other tools, the docs are published for AI readers at [bdpaygate.github.io/llms.txt](https://bdpaygate.github.io/llms.txt), with everything in one file at [llms-full.txt](https://bdpaygate.github.io/llms-full.txt). ## Callback URLs ``` /bdpaygate/{gateway}/{success|fail|cancel|ipn|callback} ``` These routes run without sessions or CSRF: several gateways return the customer with a cross-site POST, which would otherwise log them out. Register `/bdpaygate/{gateway}/ipn` with gateways that need an IPN URL. In sandbox, expose your machine with a tunnel and set `APP_URL` to it. Nagad and Upay only accept calls from whitelisted server IPs. If your servers have no fixed IP, set `BDPAYGATE_PROXY` (or a per-gateway `proxy`). ## Two stores, one gateway Each config key is a gateway name, and `driver` picks the implementation: ```php 'gateways' => [ 'sslcommerz_retail' => ['driver' => 'sslcommerz', 'store_id' => ..., 'store_password' => ...], 'sslcommerz_b2b' => ['driver' => 'sslcommerz', 'store_id' => ..., 'store_password' => ...], ], ``` Payments, routes and credentials stay separate. ## Your own gateway ```php BdPayGate::extend('mygateway', fn ($app, array $config, string $name) => new MyGateway($config, $name)); ``` See [CONTRIBUTING.md](https://bdpaygate.github.io/docs/contributing.md) for writing a driver and running it through the conformance suite. ## Development ```bash composer install composer test # Pest composer analyse # PHPStan composer format # Pint CONFORMANCE=bkash,nagad vendor/bin/pest tests/Feature/ConformanceTest.php ``` ## License MIT --- # BDPayGate integration ## Install ```bash composer require byteminus/laravel-bdpaygate php artisan vendor:publish --tag=bdpaygate-config php artisan migrate ``` Schedule the settlement command: ```php // routes/console.php Schedule::command('bdpaygate:sync')->everyTenMinutes(); ``` ## .env per gateway | Gateway | Variables | |---|---| | bKash | `BKASH_APP_KEY`, `BKASH_APP_SECRET`, `BKASH_USERNAME`, `BKASH_PASSWORD`, `BKASH_SANDBOX`, `BKASH_VERSION` (`v2` or `v1.2`) | | SSLCommerz | `SSLCOMMERZ_STORE_ID`, `SSLCOMMERZ_STORE_PASSWORD`, `SSLCOMMERZ_SANDBOX` | | ShurjoPay | `SHURJOPAY_USERNAME`, `SHURJOPAY_PASSWORD`, `SHURJOPAY_PREFIX`, `SHURJOPAY_SANDBOX` | | aamarPay | `AAMARPAY_STORE_ID`, `AAMARPAY_SIGNATURE_KEY`, `AAMARPAY_SANDBOX` | | Nagad | `NAGAD_MERCHANT_ID`, `NAGAD_MERCHANT_NUMBER`, `NAGAD_PUBLIC_KEY`, `NAGAD_PRIVATE_KEY`, `NAGAD_SANDBOX`, `NAGAD_BASE_URL`, `NAGAD_PROXY` | | Upay | `UPAY_MERCHANT_ID`, `UPAY_MERCHANT_KEY`, `UPAY_MERCHANT_CODE`, `UPAY_MERCHANT_NAME`, `UPAY_MERCHANT_MOBILE`, `UPAY_SANDBOX`, `UPAY_PROXY` | | PortPos | `PORTPOS_APP_KEY`, `PORTPOS_SECRET_KEY`, `PORTPOS_SANDBOX` | | PayStation | `PAYSTATION_MERCHANT_ID`, `PAYSTATION_PASSWORD`, `PAYSTATION_SANDBOX` | | UddoktaPay | `UDDOKTAPAY_BASE_URL`, `UDDOKTAPAY_API_KEY` | | Moneybag | `MONEYBAG_API_KEY`, `MONEYBAG_SANDBOX` | | EPS | `EPS_USERNAME`, `EPS_PASSWORD`, `EPS_HASH_KEY`, `EPS_MERCHANT_ID`, `EPS_STORE_ID`, `EPS_SANDBOX` | | DGePay | `DGEPAY_CLIENT_ID`, `DGEPAY_CLIENT_SECRET`, `DGEPAY_API_KEY`, `DGEPAY_SANDBOX` | Global: `BDPAYGATE_GATEWAY` (default gateway), `BDPAYGATE_REDIRECT_SUCCESS|FAILED|CANCELLED|PENDING` (route name or URL), `BDPAYGATE_PROXY` (fixed-IP egress for gateways that whitelist server IPs). ## Capabilities | Gateway | Refunds | Status sync | |---|---|---| | bKash | Full and partial (up to 10) | Yes | | SSLCommerz | Full and partial (asynchronous) | Yes | | PortPos | Full and partial (asynchronous) | Yes | | Upay | Full only | Yes | | ShurjoPay, aamarPay, PayStation, UddoktaPay, Nagad, EPS, DGePay | Merchant panel only | Yes | | Moneybag | Merchant panel only | No (use `verifyTransaction($payment, $transactionId)`) | Check in code with `instanceof ByteMinus\BdPayGate\Contracts\SupportsRefunds`, `SupportsPartialRefunds`, `SupportsSync`. ## Payment lifecycle 1. `charge()` creates a pending `Payment` row (one per attempt, with a fresh `gateway_reference`) and returns `checkout_url`. 2. The gateway calls `/bdpaygate/{gateway}/...`. The package ignores what the callback claims and asks the gateway's API. 3. Confirmed paid and amount matches: `Completed`, `PaymentCompleted` fires once. Confirmed declined: `Failed` or `Cancelled` (these can still become `Completed` if the gateway later confirms payment). No answer: stays `Pending` until `bdpaygate:sync`. `Payment` fields to use: `status` (`PaymentStatus` enum), `reference`, `amount`, `gateway_transaction_id`, `failure_reason`, `requires_review`, `refunded_amount`, `meta`, `paid_at`. `$payment->attempts()` lists every attempt for the same reference. ## Several stores of one gateway ```php // config/bdpaygate.php 'gateways' => [ 'sslcommerz_retail' => ['driver' => 'sslcommerz', 'store_id' => env('RETAIL_STORE_ID'), 'store_password' => env('RETAIL_STORE_PASSWORD')], 'sslcommerz_b2b' => ['driver' => 'sslcommerz', 'store_id' => env('B2B_STORE_ID'), 'store_password' => env('B2B_STORE_PASSWORD')], ], ``` ## Troubleshooting - **Payment stuck in pending:** the gateway did not answer when the callback arrived. Run `php artisan bdpaygate:sync`, or `BdPayGate::sync($payment)`. - **Customer logged out after paying:** the app added `web` middleware to the package routes. Keep `bdpaygate.routes.middleware` empty. - **"Amount mismatch":** the gateway reported a different amount than was charged; the payment is not completed. Check for coupons or currency settings. - **Nagad or Upay rejects calls in production:** they whitelist server IPs. Use a fixed-IP server or set `NAGAD_PROXY` / `UPAY_PROXY`. - **Testing UddoktaPay locally:** its sandbox drops custom ports from the return URL; use a tunnel instead of `localhost:8000`. - **Writing a custom gateway:** extend `ByteMinus\BdPayGate\Drivers\AbstractGateway`, register with `BdPayGate::extend('name', fn ($app, array $config, string $name) => new MyGateway($config, $name))`. --- # bKash **Status:** stable · **Driver:** `bkash` · **Refunds:** full and partial (up to 10 per transaction) · **Sync:** yes bKash Tokenized Checkout, URL based, without agreement: the customer is sent to bKash's page, enters wallet number, OTP and PIN, and comes back to your site. The driver speaks **Tokenized V2** by default, which has been bKash's default documentation since 2026-08-30. Merchants still provisioned on **v1.2.0-beta** set `'version' => 'v1.2'`. ## Setup ```dotenv BKASH_APP_KEY=your-app-key BKASH_APP_SECRET=your-app-secret BKASH_USERNAME=your-username BKASH_PASSWORD=your-password BKASH_SANDBOX=true BKASH_VERSION=v2 ``` ```php // config/bdpaygate.php → gateways 'bkash' => [ 'driver' => 'bkash', 'app_key' => env('BKASH_APP_KEY'), 'app_secret' => env('BKASH_APP_SECRET'), 'username' => env('BKASH_USERNAME'), 'password' => env('BKASH_PASSWORD'), 'sandbox' => env('BKASH_SANDBOX', true), 'version' => env('BKASH_VERSION', 'v2'), // "v2" or "v1.2" (v1.2.0-beta) 'mode' => '0011', // v1.2 only: URL-based checkout 'verify_signature' => true, // compare the callback signature with the one bKash issued 'execute_on_sync' => true, // sync() executes a verified payment whose callback was lost ], ``` Hosts are `https://tokenized.sandbox.bka.sh` (sandbox) and `https://tokenized.pay.bka.sh` (live). `base_url` overrides the **host only**: the driver adds `/v2/tokenized-checkout/...` or `/v1.2.0-beta/tokenized/checkout/...` itself. Sandbox credentials are issued by bKash (developer.bka.sh). Live credentials come with merchant onboarding. ### Which version am I on? | | V2 (default) | v1.2.0-beta | |---|---|---| | Paths | `/v2/tokenized-checkout/auth/grant-token`, `/auth/refresh-token`, `/payment/create`, `/payment/execute`, `/query/payment` | `/v1.2.0-beta/tokenized/checkout/token/grant`, `/token/refresh`, `/create`, `/execute`, `/payment/status` | | Field casing | `paymentId`, `trxId` | `paymentID`, `trxID` | | Success body | no `statusCode` | `statusCode: "0000"` | | Errors | `externalCode`, `errorMessageEn` | `statusCode`/`statusMessage` or `errorCode`/`errorMessage` | | `mode` on create | not sent | `0011` | | Refunds | `/v2/tokenized-checkout/refund/payment/transaction` | the same V2 endpoint, as bKash's v1.2.0-beta refund pages document | The driver reads both shapes, so switching is a config change. Payments created under one version must be executed and queried under the same version. ## How a payment is verified 1. `charge()` calls Create Payment with this attempt's `gateway_reference` as `merchantInvoiceNumber`, the customer's phone (or your reference) as `payerReference`, and `callbackURL` = `/bdpaygate/bkash/callback`. bKash's `paymentId` is stored in `gateway_payment_id`; the customer goes to `checkout_url` (`bkashURL`). 2. bKash sends the customer back to `/bdpaygate/bkash/callback?paymentID=…&status=success|failure|cancel&signature=…`. 3. The `signature` must match the one bKash put in that outcome's callback URL in the Create Payment response. A mismatch is rejected before any API call. 4. **success** means "execute now". The driver calls Execute Payment once, which is the step that moves the money: - `Completed` with the right `paymentId`, amount and currency: the payment completes and `trxId` goes to `gateway_transaction_id`. - Any error, or a timeout: the driver asks Query Payment. `Completed` completes the payment. A definite decline (for example `2023` Insufficient Balance, `2029` Duplicate for all transactions, wrong PIN, invalid wallet) marks it **Failed**, with the code in `failure_reason` (`"2023: Insufficient Balance"`). Anything ambiguous (`2056` Invalid Payment State, `2003`, `503`, HTTP 5xx, a timeout) leaves it **Pending**. - Completed for a different amount: not completed, `requires_review` is set, and the response is logged. Look at it by hand. 5. **failure** and **cancel** are claims. The driver asks Query Payment first. Only if bKash does not report `Completed` does the payment become Failed or Cancelled. Both can still become Completed later if bKash confirms payment. 6. A second success callback (refresh, back button, concurrent request) never executes again. bKash forbids reusing a paymentID after any execute attempt, so the driver queries instead. ### sync() `BdPayGate::sync($payment)` (and `bdpaygate:sync`) calls Query Payment: - `Completed`: completes. - `Failed` / `Declined` / `Expired`: Failed. `Cancelled`: Cancelled. - `Initiated`: stays pending. Once the paymentID is more than 24 hours old it can never complete, so it is marked Failed with `failure_reason` "Expired". - `Initiated` with `verificationStatus: Complete`, never executed, and under 24 hours old: the customer authorised but the callback was lost. The driver executes it (turn off with `execute_on_sync => false`). ## Refunds ```php $result = BdPayGate::refund($payment, 500, 'Damaged item'); $result->accepted; // true: bKash refunded it $result->refundId; // refundTrxId $result->message; // "Completed", or "2072: Refund amount not valid" on rejection BdPayGate::for($payment)->refundStatus($payment); // ['original_trx_id' => 'DIK80PG0SO', 'refunds' => [['refund_trx_id' => …, 'status' => 'Completed', 'amount' => '10.00', 'completed_at' => …]], 'raw' => [...]] ``` - Sends `paymentId`, `trxId`, `refundAmount`, `sku` (your reference) and `reason` to the V2 refund API. - Up to 10 refunds per transaction, within 60 days. The driver refuses an 11th. - **Timeouts.** If the refund call times out, the result is `accepted: true, pending: true` and it counts against the refundable amount, so it isn't sent twice. bKash also rejects a duplicate within 10 minutes. Call `refundStatus()` to see whether it landed before refunding again. - **Coupons.** When a coupon was applied, bKash's `maxRefundableAmount` is lower than the payment amount (amount 20.00 with a 4.00 coupon refunds at most 16.00). The driver reads it from the execute/query response and refuses a refund above it, rather than letting bKash answer `2072`. ## Tokens - Grant Token is called once and the `id_token` is cached (locked, so concurrent requests don't each grant) for about 50 minutes of its 60. - After that the stored `refresh_token` (valid 30 days on V2, 28 on v1.2.0-beta) is used. A new grant happens only when there is no refresh token or bKash refuses it. - **bKash allows two grant/refresh calls per hour. A third blocks the merchant for an hour.** The driver counts them and refuses a third locally with `GatewayUnavailableException`. Callbacks then land on your pending page and sync settles them later. - When bKash rejects a token (HTTP 401/403, `2079` Invalid app Token), the driver drops it, gets a fresh one and retries the call **once**. - Tokens live in your cache store. On several servers, use a shared cache (Redis, database). With `file` or `array` caches, each server grants its own tokens and can hit the hourly limit. Avoid `cache:clear` on deploy for the same reason. ## Limits and quirks - BDT only. `charge()` refuses other currencies before calling bKash. - A paymentID is valid for 24 hours and can be executed **once**, whatever the result. - A customer authorising on bKash's page moves no money. Only Execute does. Pending payments are recovered by `sync()` within those 24 hours. - `payerReference`, `merchantInvoiceNumber`, `sku` and `reason` may not contain `<`, `>` or `&` and are at most 255 characters. The driver strips and trims them. - `2029` (Duplicate for all transactions): the same wallet paying the same amount to you again within a few minutes is refused at execute. It is recorded as Failed with the code. - Webhooks (AWS SNS signed) are configured with bKash support and are not handled by this driver. Use `bdpaygate:sync` on a schedule. ## Sandbox notes - Keep `BKASH_SANDBOX=true` with sandbox credentials. Sandbox and live tokens are cached separately. - The token rules apply in the sandbox too. Parallel test runs against the real sandbox can use up the two calls per hour. - To see an ambiguous result, open the callback URL with `status=success` before finishing on bKash's page. Execute then fails (typically `2056` Invalid Payment State), Query says `Initiated`, and the payment stays pending. - Automated tests don't need the sandbox: fake the HTTP calls (see `tests/Conformance/BkashScenario.php` for V2 response shapes). ## Not verified against a live account These points come from bKash's documentation but haven't been tested against a live account. The driver accepts every documented variant: - V2 Query Payment documents only `Initiated` and `Completed`. `Failed`, `Declined`, `Cancelled` and `Expired` are handled in case they appear. - Its sample uses `verificationStatus`; v1.2.0-beta's table calls it `userVerificationStatus`. Both are read. - The shape of an expired-token rejection isn't documented. The driver treats HTTP 401/403, `2079`, or a bare `message` mentioning "Unauthorized" or "token" as one. - V2 grant and refresh responses carry `statusCode: "0000"` like v1.2.0-beta. A token response is accepted whenever it has an `id_token` and no error code. - The callback `signature` is documented only for v1.2.0-beta. If your V2 Create Payment response carries no signature, the check is skipped. - Refund Status takes `trxId`, which the V2 page describes ambiguously. The driver sends the original payment's `trxId`. ## Sandbox run (2026-10-15) Verified against the real bKash sandbox through a Laravel 13 app: token grant on V2 and v1.2, create, execute after wallet/OTP/PIN, two partial refunds through the V2 refund API, and refund status. A replayed success callback and a forged cancel were both rejected without changing the payment. Refund Status can take a few seconds to list a refund that was just made. --- # Nagad **Status:** beta · **Driver:** `nagad` · **Refunds:** no API (refund from the merchant portal) · **Sync:** yes > **Beta.** This driver was built from public information and has not yet been verified against a Nagad merchant sandbox. Get the official integration guide from Nagad during onboarding and check the behaviour described here against it. Please report anything that differs. Nagad's Online Payment API is a hosted checkout. The customer pays with their Nagad wallet (number, OTP, PIN) on Nagad's page. Access is not self-serve: you need a Nagad merchant account, and Nagad's key account team issues sandbox credentials by email. **Regulatory note.** Nagad is operating, but as of 2026 it runs under an interim Bangladesh Bank approval and a Bangladesh Bank appointed administrator. Its licence status after that interim period was not publicly confirmed when this driver was written. That is a business-continuity risk rather than a technical one. If Nagad matters to your checkout, consider an aggregator (ShurjoPay, aamarPay) as a fallback. ## Setup ```dotenv NAGAD_MERCHANT_ID= NAGAD_MERCHANT_NUMBER= NAGAD_PUBLIC_KEY= NAGAD_PRIVATE_KEY= NAGAD_SANDBOX=true NAGAD_BASE_URL= NAGAD_SIGNATURE_ALGORITHM=sha256 NAGAD_IP= NAGAD_PROXY= ``` ```php // config/bdpaygate.php → gateways 'nagad' => [ 'driver' => 'nagad', 'merchant_id' => env('NAGAD_MERCHANT_ID'), 'merchant_number' => env('NAGAD_MERCHANT_NUMBER'), // optional, your 11-digit merchant wallet 'public_key' => env('NAGAD_PUBLIC_KEY'), // Nagad's payment gateway public key 'private_key' => env('NAGAD_PRIVATE_KEY'), // your merchant private key 'sandbox' => env('NAGAD_SANDBOX', true), 'base_url' => env('NAGAD_BASE_URL'), // use the exact URL from your onboarding email 'signature_algorithm' => env('NAGAD_SIGNATURE_ALGORITHM', 'sha256'), 'verify_response_signature' => env('NAGAD_VERIFY_RESPONSE_SIGNATURE', false), 'ip' => env('NAGAD_IP'), // fallback for the X-KM-IP-V4 header 'proxy' => env('NAGAD_PROXY'), // fixed, whitelisted egress IP // 'api_version' => 'v-0.2.0', // 'client_type' => 'PC_WEB', // PC_WEB, MOBILE_WEB, MOBILE_APP, WALLET_WEB_VIEW // 'locale' => 'EN', // EN or BN ], ``` ### Keys There are two different keys, and mixing them up is the most common Nagad integration problem: | Key | Config | Used for | |---|---|---| | Nagad's payment gateway **public** key | `public_key` | Encrypting what you send, and checking Nagad's signatures | | Your merchant **private** key | `private_key` | Signing what you send, and decrypting Nagad's answers | - In sandbox, Nagad emails you both. In live, you generate your key pair in the Nagad merchant portal and upload the public half there. Every "generate" creates a new pair and invalidates the old one. - Keys can be given as PEM text (PKCS#1 or PKCS#8), as a bare base64 body with the header lines removed, or as an absolute path to a PEM file. PEM text in `.env` with literal `\n` sequences works too. - Never commit keys. The tests generate throwaway pairs on the fly. ### Hosts The sandbox address differs between sources, so set `base_url` to whatever your onboarding email says, including the path up to `/api/dfs`. - Default sandbox: `https://sandbox-ssl.mynagad.com/remote-payment-gateway-1.0/api/dfs`. Other addresses seen in the wild include `http://sandbox.mynagad.com:10080/remote-payment-gateway-1.0/api/dfs` and `http://sandbox.mynagad.com:30001/api/dfs`. - Default live: `https://api.mynagad.com/api/dfs`. ### Signature algorithm The algorithm used to sign requests is configurable because public sources disagree. Integrations that work in production sign with RSA-SHA256, and a signature error (`16_0006_058`) is commonly fixed by switching to it, so `sha256` is the default. Set `signature_algorithm` to `sha1` only if Nagad tells you to. `SHA256withRSA` and `SHA1withRSA` are accepted as aliases. `verify_response_signature` checks the signature Nagad puts on its encrypted answers, using the same algorithm. It is off by default because the algorithm Nagad uses for responses is not publicly confirmed. Turn it on once you have confirmed it in the sandbox. ### Callback URL The package sends its callback URL with every order: ``` https://your-app.com/bdpaygate/nagad/callback ``` In live, Nagad also asks for the callback URL in the merchant portal and whitelists the domain. Register this URL there. ## How a payment is verified 1. `charge()` calls **initialize** (`POST /check-out/initialize/{merchantId}/{orderId}`). It sends `merchantId`, `datetime`, `orderId` and a random 40-character `challenge` as JSON encrypted with Nagad's public key and signed with your private key. Nagad's answer is decrypted with your private key and gives a `paymentReferenceId`. That id is saved as `gateway_payment_id` **before** the next step, because Nagad's verify API only works by this id. 2. `charge()` then calls **complete** (`POST /check-out/complete/{paymentReferenceId}`) with the encrypted `merchantId`, `orderId`, `amount`, `currencyCode` (`050`) and the challenge, plus `merchantCallbackURL` and `additionalMerchantInfo` (your own reference is stored there as `reference`; add more with `$meta['additional_merchant_info']`). Nagad answers with the checkout URL, stored as `checkout_url`. 3. After paying, Nagad redirects the customer's browser to `/bdpaygate/nagad/callback` with `order_id`, `payment_ref_id`, `status`, `status_code` and more. **None of this is signed.** The package only uses `order_id` to find the payment. 4. The package calls **verify** (`GET /verify/payment/{paymentRefId}`) with the `paymentReferenceId` it stored, never one from the callback. The answer must carry the same `orderId` and `merchantId`. Then `status` decides: | `status` | Result | |---|---| | `Success` | Completed, once the amount matches (`issuerPaymentRefNo` stored as `gateway_transaction_id`) | | `Cancelled`, `Aborted` | Cancelled | | `Failed`, `Fraud`, `InvalidRequest`, `UnKnownFailed` | Failed | | `OrderInitiated`, `Ready`, `InProgress`, `OtpSent`, `OtpVerified`, `PinGiven`, `PartialCancelled`, anything else | Stays pending | | HTTP 404 with reason `16_0004_999` (no such payment yet) | Stays pending | `status` is used rather than `statusCode`, whose format varies. A failure callback for a payment Nagad reports as paid completes it. 5. If Nagad cannot be reached, the payment stays pending and the customer goes to your `pending` page. Nagad returns `amount` as a string with no fixed format (`"130"`, `"130.00"`). It is compared numerically. The callback is a browser redirect, so it is lost when the customer closes the tab. Schedule `bdpaygate:sync` to settle those payments. ## Refunds Nagad publishes no refund API. Refund from the Nagad merchant portal or through Nagad. `BdPayGate::refund()` throws `UnsupportedOperationException` for this gateway. If Nagad gives you a refund specification during onboarding, please open an issue. ## Limits and quirks - **IP whitelisting.** In live, Nagad only accepts calls from server IPs it has whitelisted, which especially matters for servers outside Bangladesh. A rejected call typically returns an HTML page saying "The requested URL was rejected". If your app servers have no fixed IP, set `proxy` (or the global `BDPAYGATE_PROXY`) to a proxy with a fixed, whitelisted egress IP. - **Bangladesh time.** Request timestamps are sent in Asia/Dhaka time (`yyyyMMddHHmmss`) whatever your app timezone is. Nagad rejects timestamps outside a short window (`16_0006_076`), so keep the server clock synced with NTP. - **Order ids.** Nagad accepts 5 to 20 letters and digits, and rejects a reused id on the same day (`16_0006_083`). Each attempt gets a fresh 20-character uppercase id (`gateway_reference`), so a retry never reuses one. The driver refuses to send an id outside those rules. - **`X-KM-IP-V4`** must be the customer's IPv4 address. The driver sends the request IP or `$meta['customer_ip']` when it is a public IPv4 address. Otherwise (IPv6, private addresses, local development, queued jobs and `bdpaygate:sync`) it sends `ip` from the config, so set that to your server's public IPv4. - **Small encrypted payload.** RSA-2048 can encrypt at most 245 bytes, so the encrypted JSON holds only the fields Nagad requires. - **Currency** must be `BDT`. - **Error envelope.** Errors come as `{"reason": "16_0006_xxx", "message": "..."}`. Both are included in the exception message, and the body is logged on the payment. --- # SSLCommerz **Status:** stable · **Driver:** `sslcommerz` · **Refunds:** full and partial (asynchronous) · **Sync:** yes SSLCommerz is a hosted checkout covering cards, internet banking and most mobile wallets. It is also how you accept wallets that have no public merchant API of their own: **Rocket (DBBL)**, **OK Wallet** and **IBBL / CellFin** are offered on the SSLCommerz checkout page. ## Setup ```dotenv SSLCOMMERZ_STORE_ID=yourstore SSLCOMMERZ_STORE_PASSWORD=yourstore@ssl SSLCOMMERZ_SANDBOX=true ``` ```php // config/bdpaygate.php → gateways 'sslcommerz' => [ 'driver' => 'sslcommerz', 'store_id' => env('SSLCOMMERZ_STORE_ID'), 'store_password' => env('SSLCOMMERZ_STORE_PASSWORD'), 'sandbox' => env('SSLCOMMERZ_SANDBOX', true), ], ``` Sandbox stores are self-serve at . Live refunds need your server's public IP registered with SSLCommerz. ## How a payment is verified 1. `charge()` creates a session (`/gwprocess/v4/api.php`) with this attempt's `gateway_reference` as `tran_id`, and your own reference in `value_b`. 2. SSLCommerz posts the IPN and redirects the customer (`/bdpaygate/sslcommerz/{success|fail|cancel|ipn}`). 3. A success is accepted only if the `verify_sign` signature matches **and** the Order Validation API confirms the `val_id`, the `tran_id` and the amount. For foreign-currency payments, `currency_type` and `currency_amount` are checked rather than the converted `amount`. 4. A fail or cancel is a claim anyone could send: the package asks the Transaction Query API first, and completes the payment if SSLCommerz says it was paid. 5. `risk_level = 1` completes the payment but sets `$payment->requires_review = true`. SSLCommerz asks you to hold the service and verify the customer before delivering. ## Refunds ```php $result = BdPayGate::refund($payment, 500, 'Damaged item'); $result->accepted; // true: SSLCommerz initiated it $result->pending; // true: the bank settles it later $result->refundId; // refund_ref_id BdPayGate::for($payment)->refundStatus($result->refundId); // "refunded", "processing" or "cancelled" ``` A `processing` answer means that refund was already initiated, so it is not recorded a second time. ## Limits and quirks - Amount must be 10.00 to 500000.00 BDT. Checked before the API call. - `tran_id` is at most 30 characters; attempt ids are 20. - The return to your site is a cross-site POST. The package's callback routes run without sessions, so customers are not logged out; your landing page receives a normal GET. --- # Upay **Status:** beta · **Driver:** `upay` · **Refunds:** full amount only, may be asynchronous · **Sync:** yes > **Beta.** This driver was built from public information and has not yet been verified against an Upay merchant sandbox. Get the official API document from Upay during onboarding and check the behaviour described here against it. Please report anything that differs. Upay is the mobile financial service of UCB Fintech. Its merchant payment gateway is a hosted checkout where the customer pays with their Upay wallet. Access is not self-serve: Upay issues the API document and credentials during merchant onboarding. ## Setup ```dotenv UPAY_MERCHANT_ID= UPAY_MERCHANT_KEY= UPAY_MERCHANT_CODE= UPAY_MERCHANT_NAME= UPAY_MERCHANT_MOBILE= UPAY_MERCHANT_CITY=Dhaka UPAY_MERCHANT_CATEGORY_CODE= UPAY_SANDBOX=true UPAY_BASE_URL= UPAY_PROXY= ``` ```php // config/bdpaygate.php → gateways 'upay' => [ 'driver' => 'upay', 'merchant_id' => env('UPAY_MERCHANT_ID'), 'merchant_key' => env('UPAY_MERCHANT_KEY'), 'merchant_code' => env('UPAY_MERCHANT_CODE'), 'merchant_name' => env('UPAY_MERCHANT_NAME'), 'merchant_mobile' => env('UPAY_MERCHANT_MOBILE'), 'merchant_city' => env('UPAY_MERCHANT_CITY', 'Dhaka'), 'merchant_category_code' => env('UPAY_MERCHANT_CATEGORY_CODE'), 'sandbox' => env('UPAY_SANDBOX', true), 'base_url' => env('UPAY_BASE_URL'), 'proxy' => env('UPAY_PROXY'), // fixed, whitelisted egress IP // 'merchant_country_code' => 'BD', // 'token_ttl' => 600, // seconds to cache the auth token ], ``` - All of these come from Upay. The merchant code and category code may arrive only with the live credentials. Every merchant field is sent with each payment. - Hosts: `https://uat-pg.upay.systems` (UAT) and `https://pg.upaysystem.com` (live). The live host is not stated in Upay's document; confirm it with Upay and override with `base_url` if it differs. - Currency must be `BDT`. ## How a payment is verified 1. The driver gets a token from `POST /payment/merchant-auth/` with your merchant id and key, and sends it as `Authorization: UPAY {token}` on every later call. Upay does not document an expiry, so the token is cached for `token_ttl` seconds (10 minutes by default). A 401 or 403 drops the cached token and retries once with a fresh one. 2. `charge()` calls `POST /payment/merchant-payment-init/`. The attempt's `gateway_reference` is sent as **both** `txn_id` and `invoice_id`, because the redirect only carries `invoice_id` and the status API only takes `txn_id`. Your own reference goes in `additional_info.reference`. Upay's `trx_id` is stored as `gateway_payment_id`, and the customer is sent to `gateway_url`. 3. Upay redirects the customer's browser to `/bdpaygate/upay/callback` with `status` and `invoice_id`. **This is not signed.** The package only uses it to find the payment. 4. The package calls `GET /payment/single-payment-status/{txn_id}/`. The answer must carry the same `txn_id`. Then `status` decides: | `status` | Result | |---|---| | `success` | Completed, once the amount matches (`trx_id` stored as `gateway_transaction_id`) | | `failed`, `expired` | Failed | | `cancelled` | Cancelled | | `pending`, anything else | Stays pending | | Not found (`PNF4012`) or an error | Stays pending | A failure redirect for a payment Upay reports as paid completes it. 5. If Upay cannot be reached, the payment stays pending and the customer goes to your `pending` page. `bdpaygate:sync` settles it later. ## Refunds ```php $result = BdPayGate::refund($payment); // full amount only $result->accepted; // Upay took it $result->pending; // true when Upay answered "under process" ``` - Refunds go through Upay's bulk refund endpoint (`POST /payment/bulk/refund/`) with one entry: the payment's `txn_id` and the amount as a string. - A `txn_id` in the answer's success list is an accepted refund. - A failure entry reading "Your request is under process" means Upay took the request and will settle it later. It is reported as accepted with `pending: true`, and counts against the refundable amount. Upay documents no refund status endpoint, so confirm the outcome in the merchant panel. - Any other failure entry (for example "Invalid amount" or "already refunded") is reported as not accepted, with Upay's English message in `$result->message`. - **Full refunds only.** The API takes a per-transaction amount, but partial refunds are not publicly confirmed, so `BdPayGate::refund($payment, '50.00')` throws `UnsupportedOperationException`. Make partial refunds from the merchant panel. - If the refund call times out, nothing is recorded. Check the merchant panel before you retry. ## Limits and quirks - **IP whitelisting.** Upay's live gateway is IPv4 only and expects calls from whitelisted server IPs. If your app servers have no fixed IP, set `proxy` (or the global `BDPAYGATE_PROXY`) to a proxy with a fixed, whitelisted IPv4 egress address. - **Trailing slashes.** Every Upay path ends in `/`. Without it, the server may redirect and drop the POST body. - **The redirect URL format.** Upay may append `&status=…&invoice_id=…` to the redirect URL instead of starting a query string. The package's redirect URL already carries a query parameter (`?txn=`), so the appended values still parse, and the callback also handles a second `?`. If `invoice_id` is missing, `txn` identifies the payment. Either way, the status API decides. - **Duplicate `txn_id`s are rejected** (`DTP4003`). Each attempt gets a fresh 20-character id, so a retry never reuses one. - **Bangladesh time.** The init `date` is today's date in Asia/Dhaka, whatever your app timezone is. - **The token carries your merchant key** in readable form. The driver never logs auth responses. Keep outbound request logging away from it too. - **Amounts** come back as JSON numbers (`150.0`) and are compared numerically. --- # ShurjoPay **Status:** stable · **Driver:** `shurjopay` · **Refunds:** no API (refund from the merchant panel) · **Sync:** yes ShurjoPay is a hosted checkout from a Bangladesh Bank licensed payment system operator. One integration covers: - **Cards:** Visa, Mastercard, American Express, UnionPay, Nexus Pay, Diners. - **Mobile wallets:** bKash, Nagad, **Rocket (DBBL)**, Upay, Tap, mCash, Pathao Pay, Pocket, Meghna Pay, CellFin and others ShurjoPay lists on its checkout. - **EMI** on supported cards. That makes it a way to accept Rocket and the smaller wallets that have no public merchant API of their own. The verification response's `method` field (stored in the payment's gateway log) tells you which one the customer used. ## Setup ```dotenv SHURJOPAY_USERNAME=sp_sandbox SHURJOPAY_PASSWORD=pyyk97hu&6u6 SHURJOPAY_PREFIX=sp SHURJOPAY_SANDBOX=true ``` ```php // config/bdpaygate.php → gateways 'shurjopay' => [ 'driver' => 'shurjopay', 'username' => env('SHURJOPAY_USERNAME'), 'password' => env('SHURJOPAY_PASSWORD'), 'prefix' => env('SHURJOPAY_PREFIX'), 'sandbox' => env('SHURJOPAY_SANDBOX', true), ], ``` - `prefix` is the short order prefix (at most 5 characters) agreed with ShurjoPay. ShurjoPay builds its own order id (`sp_order_id`) from it. - The values above are ShurjoPay's public sandbox credentials. Live credentials come from ShurjoPay during merchant onboarding. Sandbox tokens do not work on live. - Hosts: `https://sandbox.shurjopayment.com` (sandbox) and `https://engine.shurjopayment.com` (live). Override with `base_url` (host only, without `/api`). - Currency must be `BDT` or `USD`. ### IPN ShurjoPay can also notify a server URL about payments whose browser return was lost. Ask ShurjoPay support to register: ``` https://your-app.com/bdpaygate/shurjopay/ipn ``` The IPN is optional: `bdpaygate:sync` settles payments whose return never arrived. ## How a payment is verified 1. `charge()` gets an access token (`/api/get_token`), then creates the payment (`/api/secret-pay`). The attempt's `gateway_reference` is sent as `order_id`, and your own reference goes in `value1`. ShurjoPay's `sp_order_id` is stored as `gateway_payment_id`. 2. ShurjoPay sends the customer back to `/bdpaygate/shurjopay/success` or `/cancel` with only `?order_id=`. The return carries no status and no signature. The IPN carries the same single field. 3. Whatever the URL, the package calls the Verification API (`/api/verification`) with the `sp_order_id`. It checks that the answer is about the same `sp_order_id` and `customer_order_id`, and that the amount and currency match. Only then does `sp_code` decide: | `sp_code` | Meaning | Result | |---|---|---| | `1000` | Paid | Completed (`bank_trx_id` stored as `gateway_transaction_id`) | | `1001` | Declined by the issuing bank | Failed | | `1002` | Cancelled by the customer | Cancelled | | `1068` | Initiated, not paid yet (undocumented) | Stays pending | | anything else | Unknown | Stays pending | A cancel return for a payment ShurjoPay reports as paid completes it. A cancel return for a payment that is still "initiated" leaves it pending, because anyone can open that URL. 4. If ShurjoPay cannot be reached, the payment stays pending and the customer goes to your `pending` page. `bdpaygate:sync` settles it later. ## Refunds ShurjoPay has no public refund API. Refund from the ShurjoPay merchant panel or through ShurjoPay support. `BdPayGate::refund()` throws `UnsupportedOperationException` for this gateway. ## Limits and quirks - **Tokens last 15 minutes.** The docs sample says `expires_in: 3600` "milliseconds", but the API returns `900` seconds. The token is cached for at most 14 minutes, shared across processes, together with the `store_id` the token call returns. - **`1064 Unauthorized`** (undocumented) means the token expired or was revoked. The package fetches a new token and retries once. If ShurjoPay still refuses, the payment stays pending and the error is logged: check the username and password. - **Errors come with HTTP 200.** Verification answers are a one-element JSON array, but errors are sometimes a bare object, and `secret-pay` validation errors are an object of field messages. All of these are handled. - **`sp_code`** is a string in practice and an integer in the docs; both are accepted. - **Verification only knows ShurjoPay's id.** Asking with your own `order_id` returns `1011`. Callbacks are therefore looked up by `gateway_payment_id`, and a payment whose `secret-pay` call timed out before returning an `sp_order_id` cannot be synced. - **Amounts come back with four decimals** (`"150.0000"`). They are compared after rounding to two. - **`value4` is overwritten by ShurjoPay** in sandbox, so the package does not use it. - **Customer email** is optional at ShurjoPay; when the customer has none, a placeholder is sent. - **Return URLs are GET requests**, and IPNs are POSTs. The package's callback routes accept both, without sessions or CSRF. ## Sandbox Use the public sandbox credentials above with `SHURJOPAY_SANDBOX=true`. The sandbox checkout lives at `sandbox.securepay.shurjopayment.com`. --- # aamarPay **Status:** stable · **Driver:** `aamarpay` · **Refunds:** no API (refund from the merchant panel) · **Sync:** yes aamarPay is a hosted checkout from a Bangladesh Bank licensed payment system operator. One integration covers: - **Cards:** Visa, Mastercard, UnionPay, Nexus. - **Mobile wallets:** bKash, Nagad, **Rocket (DBBL)**, Upay, Tap, **OK Wallet**, SureCash, Qcash. - **Net banking.** That makes it a way to accept Rocket, OK Wallet and other wallets that have no public merchant API of their own. aamarPay notes that the card option only shows for amounts of 10 BDT or more. ## Setup ```dotenv AAMARPAY_STORE_ID=aamarpaytest AAMARPAY_SIGNATURE_KEY=dbb74894e82415a2f7ff0ec3a97e4183 AAMARPAY_SANDBOX=true ``` ```php // config/bdpaygate.php → gateways 'aamarpay' => [ 'driver' => 'aamarpay', 'store_id' => env('AAMARPAY_STORE_ID'), 'signature_key' => env('AAMARPAY_SIGNATURE_KEY'), 'sandbox' => env('AAMARPAY_SANDBOX', true), ], ``` - The values above are aamarPay's public sandbox credentials. Live credentials come from aamarPay support once your merchant account is set up. - Hosts: `https://sandbox.aamarpay.com` (sandbox) and `https://secure.aamarpay.com` (live). Override with `base_url`. Sandbox credentials on the live host (or the reverse) fail with a misleading "Invalid Store ID". - Currency must be `BDT` or `USD`. ### IPN aamarPay can POST a JSON notification for successful payments to a URL you give their technical support during onboarding. Give them: ``` https://your-app.com/bdpaygate/aamarpay/ipn ``` The IPN is optional: `bdpaygate:sync` settles payments whose return never arrived. ## How a payment is verified 1. `charge()` posts to `/jsonpost.php`. The attempt's `gateway_reference` is sent as `tran_id`, and your own reference in `opt_a`. The session id in the returned `payment_url` (`track=AAM…`) is aamarPay's `pg_txnid`, stored as `gateway_payment_id`. 2. aamarPay POSTs the customer's browser to `/bdpaygate/aamarpay/success` or `/fail`, with `mer_txnid`, `pg_txnid`, `status_code` and more. The cancel button returns the customer to `/bdpaygate/aamarpay/cancel?tran_id=…` with no payload. None of these are signed. 3. A callback whose `pg_txnid` differs from the stored one is rejected at once. 4. Every callback, cancel and IPN is then confirmed with the Search Transaction API (`/api/v1/trxcheck/request.php`) by `tran_id`. The answer must carry the same `mer_txnid` and `pg_txnid`. Then `status_code` decides: | `status_code` | Meaning | Result | |---|---|---| | `2` | Successful | Completed, once the amount and currency match and `pay_status` is "Successful" (`bank_trxid` stored as `gateway_transaction_id`) | | `7` | Failed | Failed | | `3` | Expired | Failed | | `0` | Initiated, not paid yet | Stays pending | | anything else | Unknown | Stays pending | A fail or cancel for a payment aamarPay reports as paid completes it. 5. If aamarPay cannot be reached, or answers in plain text instead of JSON, the payment stays pending and the customer goes to your `pending` page. `bdpaygate:sync` settles it later. For BDT payments, `amount` is compared. For USD payments, `amount` is converted to BDT, so `amount_currency` and `currency_merchant` are compared instead. A `risk_level` above 0 in the search result completes the payment but sets `$payment->requires_review = true`. ## Refunds aamarPay has no public refund API. Refund from the aamarPay merchant panel or through aamarPay support. `BdPayGate::refund()` throws `UnsupportedOperationException` for this gateway. ## Limits and quirks - **A reused `tran_id` is accepted.** aamarPay then creates a second session and Search Transaction only reports the newest one. The package sends a fresh attempt id on every charge and cross-checks `pg_txnid`, so an answer about another session never completes a payment. - **Errors come with HTTP 200.** Init errors are `{"result": false, "errors": [...]}` or `{"result": false, "message": "..."}`. Success is `"result": "true"`, a string. A wrong key on search returns plain text ("Store_id & signature key not matched"). All of these are handled. - **Missing values are placeholders.** Fields hold `"Not-Available"` or `"Not Available"` instead of null; the package treats both as empty. - **The signature key travels in the search query string.** Keep outbound request logging away from it. - **The return is a cross-site POST.** aamarPay's own troubleshooting page suggests excluding the callback from CSRF and loosening session cookies. You don't need to: the package's callback routes run without sessions or CSRF, so customers are not logged out, and your landing page receives a normal GET. - **`tran_id`** may be at most 32 characters; attempt ids are 20. - **Customer email** is required by aamarPay; when the customer has none, a placeholder is sent. - **The IPN's signature scheme is not documented**, so the IPN is treated like any other claim and confirmed with Search Transaction. ## Sandbox Use the public sandbox credentials above with `AAMARPAY_SANDBOX=true`. aamarPay's own samples note that the sandbox may only offer bKash. --- # PortPos (formerly PortWallet) **Status:** beta (built from the public v2 docs, not yet run against a sandbox) · **Driver:** `portpos` · **Refunds:** full and partial, up to 5 per invoice (asynchronous) · **Sync:** yes PortPos is Portonics' hosted invoice checkout: local and international cards, mobile wallets, net banking and EMI. **PortWallet rebranded to PortPos**: `portwallet.com` and `developer.portwallet.com` redirect to `portpos.com`, and the old API hosts (`api.portwallet.com`, `api-sandbox.portwallet.com`) no longer work. This driver only talks to `*.portpos.com`. Many older SDKs and plugins still hard-code the dead hosts. ## Setup ```dotenv PORTPOS_APP_KEY=your-app-key PORTPOS_SECRET_KEY=your-secret-key PORTPOS_SANDBOX=true ``` ```php // config/bdpaygate.php → gateways 'portpos' => [ 'driver' => 'portpos', 'app_key' => env('PORTPOS_APP_KEY'), 'secret_key' => env('PORTPOS_SECRET_KEY'), 'sandbox' => env('PORTPOS_SANDBOX', true), // 'validity' => 3600, // optional: invoice lifetime in seconds (60 to 31536000) // 'zipcode' => '1000', // optional: billing zipcode used when $meta['zipcode'] is not given ], ``` | | Sandbox | Live | |---|---|---| | API | `https://api-sandbox.portpos.com/payment/v2/` | `https://api.portpos.com/payment/v2/` | | Payment page | `https://ppv2.portpos.com/v2/invoice/{invoice_id}` | `https://payment.portpos.com/v2/invoice/{invoice_id}` | | Merchant panel | `merchant-sandbox.portpos.com` | `merchant.portpos.com` | App keys are generated in the PortPos merchant panel, and sandbox and live keys can be active at the same time. It is not clear whether the sandbox panel lets you sign up yourself; ask PortPos for a sandbox merchant account if it does not. Docs: . PortPos only accepts TLS 1.2. The auth token embeds the current Unix time, so keep the server clock NTP-synced. ## How a payment is verified 1. `charge()` creates an invoice (`POST /payment/v2/invoice`) with this attempt's `gateway_reference` as `order.reference`. The invoice id is stored in `gateway_payment_id`, and the customer is sent to the `action.url` PortPos returns. 2. Every call is authenticated with `Authorization: Bearer base64(app_key:md5(secret_key . unix_time))`, built fresh for each request. 3. PortPos sends the customer back to `/bdpaygate/portpos/callback?ref=…` and posts an IPN (`invoice`, `amount`, `status`, `reference`) to `/bdpaygate/portpos/ipn?ref=…`, for successes **and** failures. Neither is signed. 4. Every return, IPN and sync is confirmed with the IPN validate API, `GET /payment/v2/invoice/ipn/{invoice_id}/{amount}`. It is always called with the invoice id and amount **stored on the payment**, never the ones in the request. The returned `order.amount` and `order.currency` are checked as well. 5. The returned `order.status` decides the outcome: - `ACCEPTED`, `REFUND_PENDING` or `REFUND_PROCESSING` → completed. This holds even if the request claimed a failure. - `CANCELLED` → cancelled. - `EXPIRED` → failed. - `PENDING`, any other value, or an error answer → stays pending. A failed card attempt usually leaves the invoice payable. 6. PortPos has no transaction id. The processor's `billing.gateway.approve_code` is stored in `gateway_transaction_id`. ## Refunds ```php $result = BdPayGate::refund($payment, 500); // partial; null refunds the rest $result->accepted; // true: PortPos approved the request $result->pending; // true: processed asynchronously $result->refundId; // null: PortPos returns no refund id BdPayGate::for($payment)->refundStatus($payment); // the invoice state: "refund_pending", "refund_processing", "accepted", ... ``` PortPos allows up to 5 refunds per invoice, and in total no more than the invoice amount. The v2 API takes no refund reason, so `$reason` is not sent. There is no per-refund status endpoint; `refundStatus()` reads the invoice's `order.status`. ## Limits and quirks - Required customer fields, as PortPos validates them: - **Email.** The driver falls back to `noemail@example.com` when the customer has none. - **Phone**, 9 to 15 characters. - **Street.** The customer's address, or the city when there is none. - **City.** - **State.** `$meta['state']`, or the city. - **Zipcode**, 3 to 8 characters. `$meta['zipcode']`, else the config `zipcode`, else `1000`. - **Country**, as an ISO 3166 alpha-2 or alpha-3 code. `Bangladesh` is sent as `BD`. Any other country must already be given as a code, otherwise `charge()` throws before calling PortPos. - Product name and description must be at least 3 characters; the driver uses `Order {reference}` when your description is shorter. - `result` is `success` on reads and `SUCCESS` on refunds. Errors come back as `{"result":"ERROR","error":{"cause","explanation","property"}}` with a 4xx status, and the explanation is used in exception messages. - Several things are **unverified until checked against a sandbox**: - The full `order.status` vocabulary. The docs list PENDING, CANCELLED, EXPIRED, ACCEPTED, REFUND_PENDING and REFUND_PROCESSING. - What the IPN validate API returns when the amount does not match. - Whether the path amount must be `150.00` or `150`. The driver sends two decimals. - The query parameters PortPos adds to the return URL. The driver needs none of them. --- # PayStation **Status:** stable · **Driver:** `paystation` · **Refunds:** no API (refund from the merchant panel) · **Sync:** yes PayStation (Service Hub Ltd) is a hosted checkout covering cards (Visa, Mastercard) and the bKash, Nagad, Rocket and Upay wallets. "Stable-beta" means the request and response shapes below were checked against PayStation's documentation and its public sandbox, but not against a live merchant account. See [Not yet verified](#not-yet-verified). ## Setup ```dotenv PAYSTATION_MERCHANT_ID=104-1234567890 PAYSTATION_PASSWORD=your-store-password PAYSTATION_SANDBOX=true ``` ```php // config/bdpaygate.php → gateways 'paystation' => [ 'driver' => 'paystation', 'merchant_id' => env('PAYSTATION_MERCHANT_ID'), 'password' => env('PAYSTATION_PASSWORD'), 'sandbox' => env('PAYSTATION_SANDBOX', true), // 'pay_with_charge' => true, // sent as pay_with_charge=1, see below ], ``` PayStation issues separate merchant IDs for sandbox and live. Register at ; the docs are at . | Environment | API host | |---|---| | Sandbox | `https://sandbox.paystation.com.bd` | | Live | `https://api.paystation.com.bd` | ## How a payment is verified 1. `charge()` calls `POST /initiate-payment` (form fields) with `merchantId`, `password`, this attempt's `gateway_reference` as `invoice_number`, the amount as `payment_amount` (for example `"150.00"`), and your own reference in `reference`. The package's payment id goes in `opt_a`. 2. PayStation answers with `status_code: "200"` and a `payment_url`. Any other `status_code` is an error, even though the HTTP status is 200 (for example `1008` "Duplicate invoice number.", `1001` "Invalid Credential."). The package throws `GatewayException` with PayStation's message and code. 3. The customer comes back to `/bdpaygate/paystation/callback?status=Successful|Failed|Canceled&invoice_number=…&trx_id=…`. These query parameters are unsigned, so the package ignores everything except `invoice_number`. 4. The package asks `POST /transaction-status` (header `merchantId`, body `invoice_number`) and acts on `data.trx_status`: | `trx_status` (any casing) | Result | |---|---| | `success` | Completed, once `invoice_number` and the amount match. `trx_id` is stored as `gateway_transaction_id`. | | `failed` | Failed, or Cancelled if the customer's callback said `Canceled`. | | `processing` | Stays pending (the customer has not finished paying). | | `refund` | Stays pending, and the response is logged for you to review. | | any `status_code` other than `"200"`, or a non-JSON body | Stays pending. `bdpaygate:sync` asks again later. | A Failed or Cancelled payment still becomes Completed if a later status check says `success`. ### Amount check The package compares `request_amount` (or `payment_amount` when `request_amount` is missing) with the order amount. If `pay_with_charge` is off, `payment_amount` must also match exactly. Turn `pay_with_charge` on only if your PayStation account adds the processing charge to what the customer pays. The amount actually paid is then allowed to be higher than the order amount. ## Refunds PayStation documents no refund API, so this driver does not implement `SupportsRefunds`, and `BdPayGate::refund()` throws `UnsupportedOperationException`. Refund from the PayStation merchant panel. ## Limits and quirks - Business errors come back as **HTTP 200** with a string `status_code`. The driver never treats HTTP 200 alone as success. - `trx_status` casing is inconsistent: the field description says `success`, but the samples show `Success` and `Failed`. The driver lowercases and trims it before comparing. - The status endpoint is authenticated by the `merchantId` header alone, with no password and no token. That is how PayStation documents it. Treat what it returns as non-secret. - `payment_amount` is documented as an integer, but the sandbox accepts and echoes two decimal places, and the driver always sends two decimals. - `cust_email` is mandatory. If the customer has no email, the driver sends `noemail@example.com`. - PayStation's own PHP sample turns off TLS certificate checks. This package never does. - The public docs site sits behind Cloudflare and may block automated fetches. Read it in a browser. ## Not yet verified - **IPN / webhook.** PayStation's current documentation describes only the browser redirect to `callback_url`. Any server-to-server notification is configured per merchant and its payload is undocumented. If PayStation sends one to `/bdpaygate/paystation/ipn` with an `invoice_number`, it is verified through `/transaction-status` exactly like the redirect. Run `bdpaygate:sync` on a schedule either way: ```php Schedule::command('bdpaygate:sync')->everyTenMinutes(); ``` - **`pay_with_charge` semantics.** The documentation says only "Will the merchant bear the payment charge or not". Confirm with PayStation which direction `1` means for your account. - **Live behaviour.** The shapes come from the documentation and the sandbox, not from a live account. - **"Invalid Token."** One documented error mentions a token, which suggests an older token-based API exists. This driver uses only the documented `merchantId` and password endpoints. - **Status v2.** `POST /v2/transaction-status` (by PayStation `trxId`) exists but is not used. Lookups are always by our own `invoice_number`. ## Sandbox run (2026-10-15) Verified against the real PayStation sandbox through a Laravel 13 app: a 120 BDT payment paid with bKash inside PayStation's checkout, confirmed by `POST /transaction-status`. The live sandbox reports `trx_status: "successful"`, not the documented `"success"`; the driver accepts both. No refund API and no separate IPN exist (checked against the current docs): the callback to `callback_url` is the only notification, and it is never trusted on its own. --- # UddoktaPay **Status:** stable · **Driver:** `uddoktapay` · **Refunds:** no (see below) · **Sync:** yes, once the customer has submitted a payment > [!WARNING] > **UddoktaPay is not a licensed payment gateway.** It is self-hosted software that you install on your own server. Customers pay straight into mobile-wallet or bank accounts that you own, often personal bKash, Nagad, Rocket or Upay accounts, and the software then detects those payments. There is no acquirer, no payment service provider and no settlement in between. > > Collecting business payments through personal wallet accounts may breach your wallet provider's terms and Bangladesh Bank rules. You alone are responsible for your accounts, KYC, tax and regulatory compliance, disputes and refunds. This package only talks to the API your installation exposes and makes no claim that using it is permitted. If you need a licensed gateway, use SSLCommerz, bKash, Nagad, PayStation or another regulated provider. ## Setup ```dotenv UDDOKTAPAY_BASE_URL=https://pay.yourdomain.com UDDOKTAPAY_API_KEY=your-api-key UDDOKTAPAY_SANDBOX=false ``` ```php // config/bdpaygate.php → gateways 'uddoktapay' => [ 'driver' => 'uddoktapay', 'base_url' => env('UDDOKTAPAY_BASE_URL'), 'api_key' => env('UDDOKTAPAY_API_KEY'), 'sandbox' => env('UDDOKTAPAY_SANDBOX', false), ], ``` - `base_url` is **required** for live use. Use your UddoktaPay installation's own domain. You can paste the panel URL or a full API URL: anything from `/api` onwards is stripped, so `https://pay.yourdomain.com/api/checkout-v2` also works. - `api_key` comes from your UddoktaPay dashboard. It is sent in the `RT-UDDOKTAPAY-API-KEY` header. - With `sandbox` on and no `base_url`, the driver uses UddoktaPay's public sandbox, `https://sandbox.uddoktapay.com`. Its shared test API key is published on UddoktaPay's [API information](https://uddoktapay.readme.io/reference/api-information) page. With `sandbox` off and no `base_url`, `charge()` throws. - Only BDT is supported, through the `checkout-v2` API. UddoktaPay's `checkout-v2/global` variant for other currencies is not wired in. Docs: ## How a payment is verified UddoktaPay creates its `invoice_id` only after the customer submits a payment on the checkout page. Before that, the payment has no id that UddoktaPay can be asked about. 1. `charge()` calls `POST {base_url}/api/checkout-v2` with the customer's name and email, the amount as a string, and `metadata` holding this attempt's `gateway_reference`, your own reference and the package's payment id. It also sends: - `redirect_url`: `/bdpaygate/uddoktapay/success`, with `return_type: GET` - `cancel_url`: `/bdpaygate/uddoktapay/cancel?ref=…` - `webhook_url`: `/bdpaygate/uddoktapay/ipn` The customer is sent to the returned `payment_url`. 2. On success, UddoktaPay returns the customer with an `invoice_id`. It arrives as a query parameter, or as a POST field if your installation posts it; both are accepted. 3. The package calls `POST {base_url}/api/verify-payment` with that `invoice_id`. It finds the payment **from the `metadata` in the verified response**, never from the callback. - **`COMPLETED`:** completes the payment once the amount matches. `invoice_id` is stored as `gateway_payment_id` and `transaction_id` as `gateway_transaction_id`. - **`PENDING`:** the customer submitted a payment that has not been matched yet, for example while you approve it manually. The payment stays pending and the `invoice_id` is kept, so `BdPayGate::sync()` and `bdpaygate:sync` can settle it later. - **`ERROR`** (for example "No Data Found"): the callback is rejected and the payment is unchanged. 4. **Webhook (`/ipn`):** UddoktaPay's only webhook check is that the `RT-UDDOKTAPAY-API-KEY` header equals your API key. There is no signature, timestamp or replay protection. - A missing or wrong key is rejected with HTTP 400 before any API call. - A correct key is still not trusted: the posted `invoice_id` goes through `verify-payment` exactly like a redirect, and the webhook body is ignored. 5. **Cancel:** UddoktaPay's cancel link is your `cancel_url` as given, with no `invoice_id`, and verify-payment cannot report a failed or cancelled state. So a cancel cannot be confirmed: - If the payment already has an `invoice_id`, the package verifies it and completes the payment when UddoktaPay says it is paid. - Otherwise the payment stays pending and the customer is sent to your `pending` page. Show a "try again" option there. `charge()` with the same reference creates a fresh attempt. ## Refunds Not supported. UddoktaPay documents `POST /api/refund-payment`, but both its success and error responses are documented as an empty object `{}`. There is no way to tell whether a refund was accepted. Money also sits in your own wallet accounts, so refund it from there. `BdPayGate::refund()` throws `UnsupportedOperationException`. ## Limits and quirks - `amount` is sent as a string with two decimals. - `email` is mandatory. If the customer has none, the driver sends `noemail@example.com`. - The amount check uses `amount` from verify-payment, the invoice amount. `charged_amount` can differ when your installation adds a fee. - `sync()` on a payment with no `invoice_id` returns it unchanged without calling UddoktaPay. - UddoktaPay's documentation says `redirect_url` is used to build the success, failure and cancel URLs, but does not show the exact form. This driver keeps `redirect_url` free of query parameters so an appended `?invoice_id=` cannot break it. - The webhook is documented as an "IPN callback for manual data submission from the admin dashboard". Do not rely on it arriving for every payment. Schedule `bdpaygate:sync`: ```php Schedule::command('bdpaygate:sync')->everyTenMinutes(); ``` ## Sandbox The public sandbox (`https://sandbox.uddoktapay.com`, shared API key on the API information page) accepts `checkout-v2` and `verify-payment` calls. Its checkout page asks the customer to type in a wallet transaction id. Because the sandbox is shared, other people's test invoices live there too. Use it only to try the flow, never for anything real. ## Local testing note In a sandbox run (2026-10-15) the UddoktaPay sandbox dropped the port from the return URL (`http://127.0.0.1:8123/...` came back as `http://127.0.0.1/...`). Use a URL without a custom port (a tunnel, or port 80) when testing locally. Production URLs are unaffected. --- # Moneybag **Status:** stable · **Driver:** `moneybag` · **Refunds:** no public API · **Sync:** no public API (see below) Moneybag is a Bangladeshi hosted checkout (cards, mobile wallets, EMI). Its public merchant API is small: create a checkout, and verify a transaction. ## Setup ```dotenv MONEYBAG_API_KEY=your-merchant-api-key MONEYBAG_SANDBOX=true ``` ```php // config/bdpaygate.php → gateways 'moneybag' => [ 'driver' => 'moneybag', 'api_key' => env('MONEYBAG_API_KEY'), 'sandbox' => env('MONEYBAG_SANDBOX', true), // 'base_url' => env('MONEYBAG_BASE_URL'), // override the API origin ], ``` | | API origin | |---|---| | Sandbox | `https://sandbox.api.moneybag.com.bd` | | Live | `https://api.moneybag.com.bd` | Sandbox accounts are self-serve: create one at (or ). The merchant API key is shown **once**, so store it straight away. Production keys need a separate review and approval by Moneybag. The public contract lists only the sandbox origin; the live origin above comes from Moneybag's own SDK and plugin releases. Confirm it with Moneybag at go-live, and set `base_url` if they give you another. Docs: . ## How a payment is verified 1. `charge()` creates a checkout (`POST /api/v2/payments/checkout`, header `X-Merchant-API-Key`). This attempt's `gateway_reference` is sent as `order_id`, and your own reference goes in `metadata.reference`. The returned `session_id` is stored in `gateway_payment_id`, and the customer is sent to the returned `checkout_url`. 2. Moneybag returns the customer to `/bdpaygate/moneybag/{success|fail|cancel}?ref=…`, adding `transaction_id` and `status` (`SUCCESS`, `FAILED` or `CANCELLED`). 3. Neither parameter is trusted. The driver calls `GET /api/v2/payments/verify/{transaction_id}` and checks: - that the transaction's `order_id` is this attempt's `gateway_reference`; - for a payment, that `status` is `SUCCESS` **and** `verified` is `true`, and that `amount` and `currency` match. 4. A verified `SUCCESS` completes the payment, even on the fail or cancel URL. A verified `FAILED` or `CANCELLED` fails or cancels it. Anything else stays pending: an unknown transaction, a non-success answer, or `verified: false`. 5. The Moneybag `transaction_id` is stored in `gateway_transaction_id`. ## No sync, no refunds The public API can only verify by `transaction_id`. There is no lookup by order or session, and no refund API: Moneybag lists refunds, payouts and transaction history as merchant-portal features outside the public API. So: - The driver does not implement `SupportsSync` or `SupportsRefunds`, and `bdpaygate:sync` skips it. Refund from the Moneybag merchant portal. - If a customer pays but never comes back, find the transaction id in the merchant portal and settle the payment yourself: ```php BdPayGate::for($payment)->verifyTransaction($payment, 'txn…'); ``` - If the customer cancels before a transaction exists, the return has no `transaction_id`. The payment stays pending, and the customer lands on your `pending` page. ## Limits and quirks - Amount must be 10.00 to 1000000.00 BDT. Checked before the API call. - `order_id` must be 10 to 36 characters; attempt ids are 20. - `customer.email` is required by Moneybag; the driver falls back to `noemail@example.com`. Pass `$meta['postcode']` to send a postcode. - Validation errors are HTTP 422 with `{"detail":[{"loc","msg","type"}]}`; they are included in the exception message. - **Webhooks are not handled yet.** Moneybag signs webhooks (`X-Webhook-Signature: sha256=HMAC(secret, "{timestamp}.{raw body}")`), but the payment event body is not in the public contract, and webhook endpoints are configured in the merchant portal. The driver sends no `ipn_url`. - Some outcomes are **unverified until checked against a sandbox**: - The non-terminal `status` values returned by verify. Only `SUCCESS`, `FAILED` and `CANCELLED` are documented. - Whether `amount` or `charge_amount` carries the order amount for payments where the customer pays a fee. The driver checks `amount`. ## Hosts (checked 2026-10-15) Both `https://sandbox.api.moneybag.com.bd` (this driver's sandbox default) and `https://staging.api.moneybag.com.bd` (the official SDK's default) are live and require authentication. `https://api.moneybag.com.bd` is production. Override any of them with `base_url`. A merchant API key is issued from the Moneybag panel; there is no public demo key. ## Sandbox run (2026-10-15) Verified against the real Moneybag sandbox through a Laravel 13 app with a self-serve sandbox account (developer portal → sandbox onboarding issues a merchant ID and a one-time-display API key). A 120 BDT payment paid with bKash completed after `GET /api/v2/payments/verify/{transaction_id}`; a simulated failure was confirmed as `FAILED` and marked failed. The verify response reports the order amount in `amount` and Moneybag's service charge separately in `charge_amount` (the customer saw 121.20), so the amount check correctly compares `amount`. --- # EPS (Easy Payment System) **Status:** experimental · **Driver:** `eps` · **Refunds:** no (use the EPS merchant panel) · **Sync:** yes EPS is a hosted checkout covering cards (Visa, Mastercard, Amex), internet banking and most wallets (bKash, Nagad, Rocket, OK Wallet and others). > **Experimental.** EPS does not publish its API reference. This driver is built from the provider's published SDKs. Request the integration guide and sandbox credentials from EPS (integration@eps.com.bd), and verify the driver against the sandbox before going live. ## Setup ```dotenv EPS_USERNAME= EPS_PASSWORD= EPS_HASH_KEY= EPS_MERCHANT_ID= EPS_STORE_ID= EPS_SANDBOX=true ``` ```php // config/bdpaygate.php → gateways 'eps' => [ 'driver' => 'eps', 'username' => env('EPS_USERNAME'), 'password' => env('EPS_PASSWORD'), 'hash_key' => env('EPS_HASH_KEY'), 'merchant_id' => env('EPS_MERCHANT_ID'), 'store_id' => env('EPS_STORE_ID'), 'sandbox' => env('EPS_SANDBOX', true), // Optional: // 'device_type_id' => env('EPS_DEVICE_TYPE_ID'), // sent only if set // 'status_attempts' => 3, // status API tries when EPS answers 404 // 'status_retry_delay' => 1500, // milliseconds between those tries // 'ipn_window' => 120, // minutes of pending payments an IPN re-checks // 'ipn_sweep_limit' => 10, // at most this many per IPN // 'token_ttl' => 300, // seconds, if EPS gives no token expiry ], ``` All five credentials come from EPS after merchant registration and KYC. There are no public test credentials. | Environment | API host | |---|---| | Sandbox | `https://sandboxpgapi.eps.com.bd` | | Live | `https://pgapi.eps.com.bd` | ## Capabilities | | | |---|---| | Charge (hosted checkout) | yes | | Callback verification | status API, every time | | Sync (`BdPayGate::sync`, `bdpaygate:sync`) | yes | | Refunds | no. EPS's refund policy says refunds can be requested "through the API or by email", but no refund endpoint has been published. | | Currency | BDT only | ## How a payment is verified 1. `charge()` fetches a token (`/v1/Auth/GetToken`), caches it until it expires, and calls `/v1/EPSEngine/InitializeEPS`. - `merchantTransactionId` is this attempt's `gateway_reference`: 20 digits, new for every attempt. - `CustomerOrderId` is your reference plus a per-attempt suffix. - `ValueA` and `ValueB` carry the payment id and your reference. - EPS's `TransactionId` is stored in `gateway_payment_id`. 2. Every request carries `x-hash` = base64(HMAC-SHA512(hash key, value)). The signed value depends on the endpoint: - GetToken signs the username. - InitializeEPS signs the merchant transaction id. - CheckMerchantTransactionStatus signs the merchant transaction id. 3. EPS returns the customer with a GET to `/bdpaygate/eps/{success|fail|cancel}`, adding `?Status=…&MerchantTransactionId=…&EPSTransactionId=…`. The redirect is not signed, so the package ignores `Status` and calls `CheckMerchantTransactionStatus` to get the result. 4. A payment is completed only when the status API says it was paid **and** `TotalAmount` matches. EPS's transaction id goes into `gateway_payment_id` and `gateway_transaction_id`. 5. If the status API says failed or cancelled, the payment is marked that way. If it says pending, gives an unknown status, or has no answer, the payment stays pending. Use `bdpaygate:sync` to settle it later. ### The 404 after payment Right after a payment, EPS's status API can answer 404 for a short time. The driver retries 404s (`status_attempts`, `status_retry_delay`). If it still gets 404 after that, the payment stays **pending**. It is never marked failed because of a 404. ### IPN EPS's IPN body is encrypted with AES-256-CBC (`{"Data": ":"}`), and the key format is not documented, so the driver does not decrypt it. It treats any POST to `/bdpaygate/eps/ipn` as a signal: - If the body names the merchant transaction id in plain text, that payment is checked. - Otherwise the driver checks this gateway's pending payments from the last `ipn_window` minutes, up to `ipn_sweep_limit` of them, against the status API. An IPN body is never treated as proof of payment. Ask EPS support to register `https://your-app/bdpaygate/eps/ipn` as your IPN URL. ## Limits and quirks - **Errors come back with HTTP 200.** For example, a bad login gives `token: null` and an `errorMessage`. The driver reads the body, not the status code. - **Mixed key casing and literal `null`s.** Requests mix camelCase and PascalCase. Responses are read case-insensitively, and `null` or `""` counts as missing. - **Escaped return parameters.** EPS can HTML-escape the separators in the return URL (`&MerchantTransactionId=`). The driver strips the `amp;` prefix. The callback URLs it gives EPS have no query string. - **The status API is GET.** POST was reported to return 405. - **Rate limit.** EPS was seen to send `X-Rate-Limit-Limit: 40`. The time window is not known. Keep `ipn_sweep_limit` small. - **Origin/IP checks.** EPS may only accept calls from your registered domain or IP. If your servers have no fixed IP, use the `proxy` setting. ## Unverified These points come from EPS's SDKs and a production write-up, not from an official specification: - The status vocabulary. The driver accepts `Success`, `Successful`, `Completed` as paid, `Failed` and `Failure` as failed, `Cancelled` and `Aborted` as cancelled. Anything else is pending. - Whether `deviceTypeId` and `transactionDate` are required. `transactionDate` is always sent; `deviceTypeId` only when configured. - Limits on the format and length of `merchantTransactionId`. EPS's SDKs use digits only, so the driver does too. - How long a token lasts. The driver uses `expireDate` or the JWT `exp` claim, and falls back to `token_ttl`. - How IPNs are registered, how often they are retried, and what reply EPS expects. EPS's IPN page asks for `{"status":"OK"}`, but the package answers `{"ok":true,...}`. - Whether there is a refund API. --- # DGePay **Status:** experimental · **Driver:** `dgepay` · **Refunds:** no (use the DGePay merchant panel) · **Sync:** yes DGePay is a Bangladesh Bank-licensed payment service operator. Its hosted checkout covers bKash, Nagad and other mobile wallets. > **Experimental.** DGePay's API guide is only given to merchants. This driver is built from the provider's published SDK. Request the integration guide and UAT credentials from DGePay, and verify the driver against UAT before going live. ## Setup ```dotenv DGEPAY_CLIENT_ID= DGEPAY_CLIENT_SECRET= DGEPAY_API_KEY= DGEPAY_SANDBOX=true ``` ```php // config/bdpaygate.php → gateways 'dgepay' => [ 'driver' => 'dgepay', 'client_id' => env('DGEPAY_CLIENT_ID'), 'client_secret' => env('DGEPAY_CLIENT_SECRET'), 'api_key' => env('DGEPAY_API_KEY'), 'sandbox' => env('DGEPAY_SANDBOX', true), // true = UAT // Optional: // 'base_url' => 'https://api-dev.dgepay.net/dipon/v3', // DGePay's DEV environment // 'failed_status_codes' => ['4'], // status codes treated as a definite failure // 'token_ttl' => 300, // seconds, if the JWT has no exp claim ], ``` | Environment | API base | |---|---| | UAT (`sandbox: true`) | `https://api-uat.dgepay.net/dipon/v3` | | Live | `https://apiv2.dgepay.net/dipon/v3` | | DEV (set as `base_url`) | `https://api-dev.dgepay.net/dipon/v3` | ## Capabilities | | | |---|---| | Charge (hosted checkout) | yes | | Callback verification | status API, every time | | Sync (`BdPayGate::sync`, `bdpaygate:sync`) | yes | | Refunds | no. No refund endpoint has been published. | | Server-to-server notification | none known. Schedule `bdpaygate:sync`. | | Currency | BDT only | ## How a payment is verified 1. **Authenticate.** `POST /payment_gateway/authenticate` with `Authorization: Basic base64(client_id:client_secret)`, plus the same two values in the JSON body. The JWT that comes back is cached until its `exp` claim, less a minute. If DGePay answers 401, the driver fetches a new token and retries once. 2. **Initiate.** `POST /payment_gateway/initiate_payment` sends `amount`, `unique_txn_id`, `redirect_url` and `note`. It also sends `customer_token` and `payment_method` (null unless you pass `payment_method` in `$meta`), and `payee_information` when the customer phone is a Bangladeshi mobile number. - `unique_txn_id` is this attempt's `gateway_reference`, new for every attempt. - The body is **encrypted**: JSON with sorted keys → AES-128-ECB (PKCS#7) → base64. The key is the client secret, padded with `0` or cut to 16 bytes. - The `Signature` header is base64(HMAC-SHA256(api key, canonical)). *canonical* is the same sorted JSON with `{ } " : ,` and spaces removed, so `{"amount":150.0,"note":"Order 1"}` becomes `amount150.0noteOrder1`. Nulls appear as `null`, and amounts as `150.0` or `150.5`. - The customer is redirected to `data.webview_url`. 3. **Return.** DGePay returns the customer to `/bdpaygate/dgepay/callback?data=`. - Query-string parsing turns `+` into spaces, so the driver restores the `+` signs, then decrypts. - Only `unique_txn_id` is used from the decrypted payload, to find the payment. Its `status_code` is ignored. 4. **Confirm.** `POST /payment_gateway/check_transaction_status` (encrypted and signed in the same way) gives the result: | `data.status_code` | Result | |---|---| | `3` | Completed, if `data.amount` matches | | `8` | Cancelled | | `4` (`failed_status_codes`) | Failed | | anything else, or no `data` | stays pending | On completion, `txn_id` goes into `gateway_payment_id` and `txn_number` into `gateway_transaction_id`. ## Limits and quirks - **Errors come back with HTTP 200.** The body has `"success": 0` and an `error` list. The driver reads the body. - **Plain JSON is rejected.** DGePay answers it with `REQUESTED_PARAMETERS_ARE_MISMATCHED`. Every call after authentication must be encrypted and signed. - **AES-ECB is DGePay's choice.** It is a weak mode, used here only because the API requires it. Keep the client secret and API key out of version control. - **Signature formatting is strict.** Numeric-looking strings (phone numbers, `+88`) are signed as given. Only real numbers are rendered as JSON numbers. ## Unverified These points come from DGePay's published SDK, not from the request-only API guide: - The full status-code table. `3` (success) and `8` (cancelled) are documented. `4` comes from an SDK sample whose message is "TRANSACTION FAILED". Every other code is treated as pending. - How amounts with two decimal places (`150.25`) appear in the signature. The driver uses the JSON form, `150.25`. One unofficial client rounds to one decimal place instead. - Whether `authenticate` also needs the body credentials. The SDK sends Basic auth only. This driver sends both. - Whether `payee_information` and `payment_method` are validated. - How long a token lasts, beyond the JWT `exp` claim. - Whether DGePay has a webhook/IPN or a refund API. None is known. - How to get UAT or DEV credentials. --- # Contributing Issues and pull requests are welcome. The ground rules are what make this package worth using: - **No test hits a real gateway.** Every test uses `Http::fake()`. - **Never trust a callback.** Redirects, IPNs and webhooks are claims. A driver confirms the payment with the gateway's own API, checks the amount, and only then changes state. - **Uncertain means pending.** A timeout or an ambiguous answer leaves the payment pending; `bdpaygate:sync` settles it later. Only a definite answer from the gateway marks a payment failed. - Run `composer check` (Pint, PHPStan, Pest) before opening a PR. ## Adding a gateway 1. Write `src/Drivers/YourGateway.php`: extend `AbstractGateway`, set `public const DRIVER`, implement `charge()` and `handleCallback()`. Add `SupportsSync` if the gateway has a status API, and `SupportsRefunds` (with the `HandlesRefunds` trait) or `SupportsPartialRefunds` if it has a refund API. 2. Use the base class: `send()` and `decode()` for HTTP, `createPendingPayment()`, `findByGatewayReference()`, `assertAmountMatches()`, `transition()` to change state, `stillPending()` when the gateway can't confirm, `rememberToken()` for access tokens. 3. Send `$payment->gateway_reference` to the gateway as the merchant order id. It is unique per attempt; `$payment->reference` is the merchant's own and repeats across retries. 4. Register it in `BdPayGateManager::DRIVERS` and add its block to `config/bdpaygate.php`. 5. Add `tests/Conformance/YourGatewayScenario.php` (see `tests/Support/GatewayScenario.php`). The shared suite in `tests/Feature/ConformanceTest.php` then checks it for forged callbacks, amount mismatches, replayed IPNs, races, timeouts, late successes, sync and refunds. **A gateway is supported when it passes.** 6. Add gateway-specific tests in `tests/Feature/YourGatewayTest.php` and a page in `docs/gateways/`. `src/Drivers/SslCommerzGateway.php` with `tests/Conformance/SslCommerzScenario.php` is the reference.