ux2dev / epay-easypay
PHP SDK for ePay.bg / EasyPay payment gateway (WEB API, One Touch, Billing)
Requires
- php: ^8.2
- ext-json: *
- ext-mbstring: *
- ext-openssl: *
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
Requires (Dev)
- guzzlehttp/guzzle: ^7.0
- orchestra/testbench: ^10.0
- pestphp/pest: ^4.0
Suggests
- guzzlehttp/guzzle: Required for One Touch API (PSR-18 client + PSR-17 factories)
- illuminate/support: ^11.0|^12.0
README
Warning: This is a developer testing version of the library -- use at your own risk.
A framework-agnostic PHP SDK for the ePay.bg and EasyPay payment gateway. Covers all three APIs: WEB, One Touch, and Billing. Works with plain PHP or Laravel.
Requirements
- PHP 8.2 or higher
- OpenSSL extension (signing, key generation)
- JSON extension (One Touch + Billing responses)
- mbstring extension
- iconv extension (EasyPay response decoding, CP-1251 obligation files)
- A PSR-18 HTTP client and PSR-17 request/stream factories (required for One Touch and EasyPay; Laravel installs already ship
guzzlehttp/guzzletransitively, which provides both)
Installation
composer require ux2dev/epay-easypay
Quick Start
Plain PHP
use Ux2Dev\Epay\Config\MerchantConfig; use Ux2Dev\Epay\Enum\Environment; use Ux2Dev\Epay\Web\WebClient; $config = new MerchantConfig( merchantId: '1000000000', // Your KIN from ePay.bg profile secret: 'your_secret_word', // Secret word from ePay.bg profile environment: Environment::Production, ); $web = new WebClient($config); $request = $web->createPaymentRequest( invoice: 'INV-001', amount: '22.80', expirationDate: '01.08.2026', description: 'Monthly fee', ); // Render a form that submits to ePay.bg echo '<form action="' . $request->getGatewayUrl() . '" method="POST">'; foreach ($request->toArray() as $name => $value) { echo '<input type="hidden" name="' . $name . '" value="' . $value . '">'; } echo '<button type="submit">Pay with ePay.bg</button>'; echo '</form>';
Laravel
// In your controller use Ux2Dev\Epay\Laravel\EpayFacade as Epay; $request = Epay::web()->createPaymentRequest( invoice: 'INV-001', amount: '22.80', expirationDate: '01.08.2026', ); return view('payment', [ 'gatewayUrl' => $request->getGatewayUrl(), 'fields' => $request->toArray(), ]);
Configuration
MerchantConfig
Every client requires a MerchantConfig instance. This is an immutable, readonly object that validates all inputs at construction time.
use Ux2Dev\Epay\Config\MerchantConfig; use Ux2Dev\Epay\Enum\Currency; use Ux2Dev\Epay\Enum\Environment; use Ux2Dev\Epay\Enum\SigningMethod; $config = new MerchantConfig( merchantId: '1000000000', // Required. Your KIN from ePay.bg secret: 'your_secret_word', // Required. Secret word from ePay.bg environment: Environment::Production, // Required. Production or Development currency: Currency::EUR, // Optional. Default: EUR. Also: BGN, USD signingMethod: SigningMethod::HmacSha1, // Optional. Default: HmacSha1. Also: Rsa privateKey: null, // Optional. PEM string or file path. Required when signingMethod is Rsa privateKeyPassphrase: null, // Optional. Passphrase for encrypted private key );
Environments:
| Environment | Gateway URL | One Touch Base URL |
|---|---|---|
Environment::Development |
https://demo.epay.bg/ |
https://demo.epay.bg/xdev/api |
Environment::Production |
https://www.epay.bg/ |
https://www.epay.bg/xdev/api |
Use Environment::Development for testing. ePay.bg provides a demo environment at https://demo.epay.bg/ where you can test payments without real money.
Security: MerchantConfig protects sensitive data. The secret and privateKey fields are private and accessible only through getter methods (getSecret(), getPrivateKey(), getPrivateKeyPassphrase()). They are redacted in var_dump() output and the object cannot be serialized.
Enums
All enums live under Ux2Dev\Epay\Enum\ (or Ux2Dev\Epay\Billing\Enum\ for Billing-specific ones):
| Enum | Cases | Used for |
|---|---|---|
Currency |
BGN, EUR, USD |
Payment currency |
Environment |
Development, Production |
Gateway + One Touch URLs |
SigningMethod |
HmacSha1, Rsa |
Outbound request signing |
PaymentStatus |
Paid, Denied, Expired |
WEB notification result |
TransactionType |
Payment (paylogin), CreditPayDirect (credit_paydirect) |
WEB gateway PAGE value |
BillingRequestType |
Check, Billing, Deposit |
Incoming /billing/init TYPE |
BillingPaymentType |
Billing, Partial, Deposit |
Incoming /billing/confirm TYPE |
BillingStatus |
Success (00), InvalidAmount (13), InvalidSubscriber (14), NoObligation (62), Unavailable (80), InvalidChecksum (93), Duplicate (94), GeneralError (96) |
Billing response STATUS codes |
Laravel Configuration
Publish the config file:
php artisan vendor:publish --tag=epay-config
This creates config/epay.php:
return [ 'default' => 'main', 'merchants' => [ 'main' => [ 'merchant_id' => env('EPAY_MERCHANT_ID'), 'secret' => env('EPAY_SECRET'), 'environment' => env('EPAY_ENVIRONMENT', 'production'), 'currency' => env('EPAY_CURRENCY', 'EUR'), 'signing_method' => env('EPAY_SIGNING_METHOD', 'hmac'), 'private_key' => env('EPAY_PRIVATE_KEY'), 'private_key_passphrase' => env('EPAY_PRIVATE_KEY_PASSPHRASE'), 'url_ok' => env('EPAY_URL_OK'), 'url_cancel' => env('EPAY_URL_CANCEL'), 'notification_url' => env('EPAY_NOTIFICATION_URL'), ], ], 'routes' => [ 'enabled' => env('EPAY_ROUTES_ENABLED', false), 'prefix' => env('EPAY_ROUTES_PREFIX', 'epay'), 'middleware' => [], ], ];
Add to your .env:
EPAY_MERCHANT_ID=1000000000
EPAY_SECRET=your_secret_word
EPAY_ENVIRONMENT=development
EPAY_CURRENCY=EUR
EPAY_URL_OK=https://yoursite.com/payment/success
EPAY_URL_CANCEL=https://yoursite.com/payment/cancel
Multi-tenancy (Multiple Merchants)
Add additional merchants to the config:
'merchants' => [ 'main' => [ 'merchant_id' => env('EPAY_MERCHANT_ID'), 'secret' => env('EPAY_SECRET'), // ... ], 'building_2' => [ 'merchant_id' => env('EPAY_BUILDING2_MERCHANT_ID'), 'secret' => env('EPAY_BUILDING2_SECRET'), 'environment' => 'production', 'currency' => 'EUR', 'signing_method' => 'hmac', ], ],
Use a specific merchant:
use Ux2Dev\Epay\Laravel\EpayFacade as Epay; // Default merchant Epay::web()->createPaymentRequest(...); // Specific merchant Epay::merchant('building_2')->web()->createPaymentRequest(...); Epay::merchant('building_2')->billing()->parseInitRequest(...);
The facade exposes one client method per API:
| Method | Returns | Purpose |
|---|---|---|
Epay::web() |
WebClient |
WEB API (browser form payments + notifications) |
Epay::billing() |
BillingHandler |
Billing API (EasyPay obligation feed) |
Epay::oneTouch() |
OneTouchClient |
One Touch API (tokenized mobile/web payments) |
Epay::easyPay() |
EasyPayClient |
EasyPay cash desk code generation |
Epay::getConfig() |
MerchantConfig |
Resolved config for the current merchant |
Epay::merchant($name) |
EpayManager |
Switch to another configured merchant |
oneTouch() and easyPay() are wired with GuzzleHttp\Client and GuzzleHttp\Psr7\HttpFactory automatically.
WEB API
The WEB API handles browser-based payments. The flow is:
- Your server creates a payment request with signed data
- You render an HTML form that POSTs to ePay.bg
- The customer pays on ePay.bg
- ePay.bg sends a callback (notification) to your server
- ePay.bg redirects the customer back to your site
Creating a WebClient
use Ux2Dev\Epay\Web\WebClient; $web = new WebClient($config);
In Laravel:
$web = Epay::web();
Payment Request (Standard)
Creates a signed payment request using the ENCODED + CHECKSUM flow. This is the most common payment method.
$request = $web->createPaymentRequest( invoice: 'INV-001', // Required. Your invoice number amount: '22.80', // Required. Amount > 0.01 expirationDate: '01.08.2026', // Required. Format: DD.MM.YYYY description: 'Monthly maintenance fee', // Optional. Max 100 characters encoding: 'utf-8', // Optional. Set to 'utf-8' for UTF-8 descriptions email: null, // Optional. Merchant email (alternative to MIN) discount: null, // Optional. Card BIN discount rules urlOk: 'https://yoursite.com/success', // Optional. Redirect URL on success urlCancel: 'https://yoursite.com/cancel', // Optional. Redirect URL on cancel );
The returned PaymentRequest object contains everything you need to render the payment form:
$gatewayUrl = $request->getGatewayUrl(); // https://www.epay.bg/ or https://demo.epay.bg/ $formFields = $request->toArray(); // ['PAGE' => 'paylogin', 'ENCODED' => '...', 'CHECKSUM' => '...', ...]
Render the form in your HTML:
<form action="{{ $gatewayUrl }}" method="POST"> @foreach($formFields as $name => $value) <input type="hidden" name="{{ $name }}" value="{{ $value }}"> @endforeach <button type="submit">Pay Now</button> </form>
Direct Card Payment Request
Same as the standard payment, but the customer enters card details directly on a page hosted by ePay.bg (no ePay.bg login required). Supports a language parameter.
$request = $web->createDirectPaymentRequest( invoice: 'INV-001', amount: '22.80', expirationDate: '01.08.2026', lang: 'en', // 'bg' or 'en'. Default: 'bg' description: 'Monthly fee', urlOk: 'https://yoursite.com/success', urlCancel: 'https://yoursite.com/cancel', ); // Same form rendering as above // PAGE will be 'credit_paydirect' instead of 'paylogin'
Bank Transfer Request
Initiates a bank transfer. Does not use ENCODED/CHECKSUM. Fields are sent directly.
$request = $web->createBankTransferRequest( merchant: 'Company Name Ltd.', // Required. Merchant name iban: 'BG80BNBG96611020345678', // Required. Valid IBAN bic: 'BNBGBGSD', // Required. Valid BIC total: '22.80', // Required. Amount > 0.01 statement: 'Monthly fee April 2026', // Required. Payment statement pstatement: '123456', // Required. Exactly 6 digits urlOk: 'https://yoursite.com/success', urlCancel: 'https://yoursite.com/cancel', );
Simple Payment Request
A simplified variant that sends fields directly without encoding. For merchants that do not need the ENCODED/CHECKSUM flow.
$request = $web->createSimplePaymentRequest( invoice: 'INV-001', // Required total: '22.80', // Required. Amount > 0.01 description: 'Monthly fee', // Optional encoding: 'utf-8', // Optional urlOk: 'https://yoursite.com/success', urlCancel: 'https://yoursite.com/cancel', );
Handling Payment Notifications (Callbacks)
After a customer pays, ePay.bg sends an HTTP POST to your notification URL with ENCODED and CHECKSUM parameters. The SDK verifies the CHECKSUM before parsing.
// In your callback endpoint (e.g. POST /epay/notify) $result = $web->handleNotification($_POST); foreach ($result->items() as $item) { // $item->invoice - Your invoice number // $item->status - PaymentStatus::Paid, PaymentStatus::Denied, or PaymentStatus::Expired // $item->payTime - DateTimeImmutable (only when Paid) // $item->stan - Transaction number (only when Paid) // $item->bcode - Authorization code (only when Paid) // $item->amount - Discounted amount (only when discount applied) // $item->bin - Card BIN (only when discount applied) if ($item->status === \Ux2Dev\Epay\Enum\PaymentStatus::Paid) { // Mark invoice as paid in your database $item->acknowledge(); // Tell ePay: OK, received } else { $item->notFound(); // Tell ePay: unknown invoice (or use reject() to retry later) } } // Return the response to ePay.bg header('Content-Type: text/plain'); echo $result->toHttpResponse();
In Laravel:
// routes/web.php Route::post('/epay/notify', function (Request $request) { $result = Epay::web()->handleNotification($request->all()); foreach ($result->items() as $item) { if ($item->status === PaymentStatus::Paid) { Invoice::where('number', $item->invoice)->update(['paid' => true]); $item->acknowledge(); } else { $item->notFound(); } } return response($result->toHttpResponse(), 200) ->header('Content-Type', 'text/plain'); });
Response statuses:
| Method | ePay Status | Meaning |
|---|---|---|
$item->acknowledge() |
OK | Received successfully. ePay stops sending. |
$item->reject() |
ERR | Error processing. ePay will retry. |
$item->notFound() |
NO | Unknown invoice. ePay stops sending. |
Retry schedule: ePay retries on ERR or no response for up to 30 days: 5 times under 1 minute, 4 times every 15 minutes, 5 times every hour, 6 times every 3 hours, 4 times every 6 hours, 1 time daily.
RSA Signing (Optional)
For additional security, you can sign requests with RSA in addition to HMAC-SHA1. The HMAC CHECKSUM is always present; the RSA SIGNATURE is additive.
$config = new MerchantConfig( merchantId: '1000000000', secret: 'your_secret_word', environment: Environment::Production, signingMethod: SigningMethod::Rsa, privateKey: file_get_contents('/path/to/private_key.pem'), privateKeyPassphrase: 'optional_passphrase', ); $web = new WebClient($config); $request = $web->createPaymentRequest(...); // $request->toArray() will now include both CHECKSUM and SIGNATURE
Generate an RSA key pair:
use Ux2Dev\Epay\KeyGenerator\RsaKeyGenerator; $keys = RsaKeyGenerator::generate( keyBits: 2048, passphrase: 'optional_passphrase', ); $keys->saveToDirectory('/path/to/keys'); // Creates: epay_private.key and epay_public.key // Upload epay_public.key to your ePay.bg merchant profile
In Laravel:
php artisan epay:generate-key \
--output=/path/to/keys \ # Defaults to current working directory
--bits=2048 \ # RSA key size. Default: 2048
--passphrase=optional # Encrypt the private key (optional)
Billing API
The Billing API handles periodic payments (utility bills, maintenance fees, subscriptions). The flow is the opposite of the WEB API: ePay.bg calls YOUR server.
- A customer goes to EasyPay or ePay.bg and enters their subscriber number (IDN)
- ePay.bg calls your
/pay/initendpoint: "How much does subscriber X owe?" - Your server responds with the obligation amount
- The customer pays
- ePay.bg calls your
/pay/confirmendpoint: "Subscriber X paid" - Your server confirms
Creating a BillingHandler
use Ux2Dev\Epay\Billing\BillingHandler; $billing = new BillingHandler($config);
In Laravel:
$billing = Epay::billing();
Handling /pay/init (Obligation Check)
When ePay.bg asks "How much does subscriber X owe?":
// Your endpoint receives GET parameters from ePay.bg // e.g. GET /pay/init?IDN=12345&MERCHANTID=0000334&TYPE=CHECK&CHECKSUM=... $initRequest = $billing->parseInitRequest($_GET); // CHECKSUM is automatically verified. Throws InvalidResponseException on mismatch. // $initRequest->idn - Subscriber identifier (string) // $initRequest->merchantId - Your merchant ID (string) // $initRequest->type - BillingRequestType::Check, Billing, or Deposit // $initRequest->tid - Transaction ID (only for Billing/Deposit) // $initRequest->total - Amount in stotinki (only for Deposit)
Build and return the response:
use Ux2Dev\Epay\Billing\Response\InitResponse; use Ux2Dev\Epay\Billing\Response\Invoice; // Find the subscriber in your database $apartment = Apartment::findByEpayId($initRequest->idn); if (!$apartment) { header('Content-Type: application/json'); echo InitResponse::invalidSubscriber($initRequest->idn)->toJson(); return; } $obligations = $apartment->unpaidObligations(); if ($obligations->isEmpty()) { header('Content-Type: application/json'); echo InitResponse::noObligation($initRequest->idn)->toJson(); return; } // Return the obligations header('Content-Type: application/json'); echo InitResponse::success( idn: $initRequest->idn, shortDesc: $apartment->ownerName . ', ap. ' . $apartment->number, amount: $obligations->totalInStotinki(), // e.g. 8000 = 80.00 lv validTo: new DateTimeImmutable('2026-05-01'), longDesc: "Maintenance fee 50.00\nElevator 30.00\nTotal 80.00", invoices: [ new Invoice( idn: $initRequest->idn . '.001', amount: 5000, shortDesc: 'Maintenance fee', validTo: new DateTimeImmutable('2026-05-01'), ), new Invoice( idn: $initRequest->idn . '.002', amount: 3000, shortDesc: 'Elevator', validTo: new DateTimeImmutable('2026-05-01'), ), ], )->toJson();
Available response methods:
| Method | STATUS | When to use |
|---|---|---|
InitResponse::success(...) |
00 | Subscriber found, has obligations |
InitResponse::noObligation($idn) |
62 | Subscriber found, no obligations |
InitResponse::invalidSubscriber($idn) |
14 | Unknown subscriber |
InitResponse::invalidAmount() |
13 | Invalid deposit amount |
InitResponse::unavailable() |
80 | Temporarily unavailable |
InitResponse::error() |
96 | General error |
Handling /pay/confirm (Payment Confirmation)
When ePay.bg tells you "Subscriber X paid":
// GET /pay/confirm?IDN=12345&MERCHANTID=0000334&TID=...&DATE=...&TOTAL=16600&TYPE=BILLING&CHECKSUM=... $confirmRequest = $billing->parseConfirmRequest($_GET); // CHECKSUM is automatically verified. // $confirmRequest->idn - Subscriber identifier // $confirmRequest->merchantId - Your merchant ID // $confirmRequest->tid - Transaction ID (26 chars: DATE14 + STAN6 + AID6) // $confirmRequest->date - Payment timestamp (DateTimeImmutable) // $confirmRequest->total - Amount in stotinki (int) // $confirmRequest->type - BillingPaymentType::Billing, Partial, or Deposit // $confirmRequest->invoices - Comma-separated invoice IDNs or null
Build and return the response:
use Ux2Dev\Epay\Billing\Response\ConfirmResponse; // Check for duplicate (same TID) if (Payment::where('tid', $confirmRequest->tid)->exists()) { header('Content-Type: application/json'); echo ConfirmResponse::duplicate()->toJson(); // STATUS=94 return; } // Record the payment Payment::create([ 'idn' => $confirmRequest->idn, 'tid' => $confirmRequest->tid, 'total' => $confirmRequest->total, 'paid_at' => $confirmRequest->date, ]); header('Content-Type: application/json'); echo ConfirmResponse::success()->toJson(); // STATUS=00
Available response methods:
| Method | STATUS | When to use |
|---|---|---|
ConfirmResponse::success() |
00 | Payment recorded |
ConfirmResponse::duplicate() |
94 | Already processed (same TID) |
ConfirmResponse::invalidChecksum() |
93 | Bad checksum |
ConfirmResponse::error() |
96 | General error |
CHECKSUM Calculation (Billing)
The Billing API uses a different CHECKSUM algorithm than the WEB API. The SDK handles this automatically, but for reference:
- Collect all GET parameters except CHECKSUM
- Sort alphabetically by parameter name
- Concatenate as
KEY1VALUE1\nKEY2VALUE2\n...\n— no separator between key and value,\nbetween pairs, and a trailing\nafter the last pair - HMAC-SHA1 with your secret word
The trailing newline is easy to miss and was the source of a real production bug — real ePay requests would fail CHECKSUM verification without it. If you need to compute the canonical data string outside the SDK (e.g. for tooling or tests), call
BillingHandler::buildChecksumData($params).
Subscriber IDN Numbers
The IDN (subscriber identifier) is your internal number. ePay requires it to be digits only, max 64 characters — no letters, no dashes, no separators. Same constraint applies to sub-invoice IDNs (e.g. 2000001001 for parent 2000001, not 2000001-F001). The SDK provides a helper:
use Ux2Dev\Epay\IdnGenerator\IdnGenerator; // Simple concatenation $idn = IdnGenerator::generate('001', '0012'); // '0010012' // Fixed-length with padding $idn = IdnGenerator::padded('001', 12, 10); // '0010000012' // Parse back $parts = IdnGenerator::parse('0010000012', 3); // ['prefix' => '001', 'subscriberId' => '0000012'] // Validate IdnGenerator::validate('12345'); // OK IdnGenerator::validate('ABC123'); // Throws ConfigurationException
LONGDESC Formatting
The LONGDESC field in Billing responses uses special escape sequences:
use Ux2Dev\Epay\Billing\Formatter\LongDescFormatter; // Encode for ePay $encoded = LongDescFormatter::encode("Line 1\nLine 2\n--------\nCol1\tCol2"); // Result: 'Line 1\nLine 2\n\$\nCol1\tCol2' // Decode from ePay $decoded = LongDescFormatter::decode('Line 1\nLine 2'); // Validate line length (max 110 characters per line) LongDescFormatter::validate($text); // Throws ConfigurationException if any line > 110 chars
Obligation File Exchange
For batch processing, generate obligation files for upload to mrcs.easypay.bg:
use Ux2Dev\Epay\Billing\FileExchange\ObligationFileGenerator; $file = ObligationFileGenerator::create('20260413120000') // Session: YYYYMMDDHHmmss ->addObligation(subscriberId: '12345', amount: 8000, name: 'Ivan Ivanov') ->addObligation(subscriberId: '12346', amount: 6500, name: 'Petar Petrov') ->addObligation( subscriberId: '12347', amount: 12000, name: 'Maria Georgieva', address: 'Sofia, ul. Rakovski 1', dueDate: '20260501', ); $file->saveTo('/path/to/obligations.txt');
The file is generated in Windows CP-1251 encoding with pipe (|) delimiters, as required by ePay.bg. Amounts are in stotinki (8000 = 80.00 lv). Each subscriber can appear only once.
In Laravel:
php artisan epay:generate-obligations /path/to/output.txt --session=20260413120000
One Touch API
The One Touch API enables tokenized payments for mobile and web applications. Instead of redirecting to ePay.bg each time, the customer authorizes once, and you receive a token for future payments.
Creating an OneTouchClient
use Ux2Dev\Epay\OneTouch\OneTouchClient; use GuzzleHttp\Client; $oneTouch = new OneTouchClient($config, new Client());
In Laravel:
$oneTouch = Epay::oneTouch();
Token Acquisition (Three Steps)
Step 1: Generate authorization URL
Redirect the customer to this URL. They will log in to ePay.bg and authorize your application.
$authUrl = $oneTouch->getAuthorizationUrl( deviceId: 'user@example.com', // Unique device/user identifier key: bin2hex(random_bytes(16)), // Unique key for this authorization userType: null, // 1 = ePay users only, 2 = cards only, null = both deviceName: 'My App', // Optional brand: null, // Optional. Device brand os: 'Web', // Optional model: null, // Optional osVersion: null, // Optional phone: null, // Optional ); // Redirect the customer header('Location: ' . $authUrl);
Step 2: Poll for authorization code
After the customer authorizes, poll for the code. Recommended: every 20-30 seconds, up to 30 minutes.
$response = $oneTouch->getCode( deviceId: 'user@example.com', key: 'the_same_key_from_step_1', ); if ($response->status === 'OK') { $code = $response->code; // Use this in Step 3 } // If status is not 'OK', the customer hasn't authorized yet. Retry later.
Step 3: Exchange code for token
$token = $oneTouch->getToken( deviceId: 'user@example.com', code: $code, ); // Save these for future use: // $token->token - The access token // $token->expires - Unix timestamp when token expires // $token->kin - Customer's KIN // $token->username - Customer's username // $token->realName - Customer's real name
Token Management
// Revoke a token $oneTouch->invalidateToken( deviceId: 'user@example.com', token: $savedToken, );
User Information
$userInfo = $oneTouch->getUserInfo( deviceId: 'user@example.com', token: $savedToken, withPaymentInstruments: true, // Include cards and accounts ); // $userInfo->gsm - Phone number // $userInfo->realName - Full name // $userInfo->kin - Customer KIN // $userInfo->email - Email foreach ($userInfo->paymentInstruments as $instrument) { // $instrument->id - Instrument ID (use for payments) // $instrument->name - e.g. "Visa ****1234" // $instrument->type - 1 = card, 2 = micro-account // $instrument->balance - Balance in stotinki // $instrument->verified - Whether verified // $instrument->expires - Expiration date }
Payment Flow (Four Steps)
Step 1: Initialize payment
$payment = $oneTouch->initPayment( deviceId: 'user@example.com', token: $savedToken, ); $paymentId = $payment->id;
Step 2: Check payment (get fees)
$check = $oneTouch->checkPayment( deviceId: 'user@example.com', token: $savedToken, paymentId: $paymentId, amount: 2280, // Amount in stotinki (22.80 lv) recipient: '8888', // Recipient KIN recipientType: 'KIN', description: 'Monthly maintenance fee', reason: 'monthly_fee', paymentInstrumentId: $instrumentId, // From getUserInfo show: 'KIN', // What recipient sees: KIN, GSM, EMAIL, NAME ); // $check->amount - Payment amount // Review fees per instrument before sending
Step 3: Send payment
$result = $oneTouch->sendPayment( deviceId: 'user@example.com', token: $savedToken, paymentId: $paymentId, amount: 2280, recipient: '8888', recipientType: 'KIN', description: 'Monthly maintenance fee', reason: 'monthly_fee', paymentInstrumentId: $instrumentId, show: 'KIN', ); // $result->state - 2 = processing, 3 = success, 4 = failure // $result->stateText - Human-readable status // $result->no - Payment number
Step 4: Check payment status
$status = $oneTouch->getPaymentStatus( deviceId: 'user@example.com', token: $savedToken, paymentId: $paymentId, ); if ($status->state === 3) { // Payment successful }
No-Registration Payments
Allow card payments without user registration or token. The customer is redirected to ePay.bg to enter card details.
$paymentUrl = $oneTouch->createNoRegPaymentUrl( deviceId: 'user@example.com', id: 'NOREG-' . bin2hex(random_bytes(6)), // Your unique payment ID (echoed back) amount: 2280, recipient: '8888', recipientType: 'KIN', description: 'Monthly maintenance fee', reason: 'monthly_fee', saveCard: false, // true to save card for future payments ); // Redirect the customer header('Location: ' . $paymentUrl); // Later, check the payment status. The status endpoint requires the same // params used at create (they feed into CHECKSUM), including the `id` that // ePay echoes back in the redirect query string. $status = $oneTouch->getNoRegPaymentStatus( deviceId: 'user@example.com', paymentId: $_GET['id'], amount: 2280, recipient: '8888', recipientType: 'KIN', description: 'Monthly maintenance fee', reason: 'monthly_fee', ); // $status->state - 2 = pending, 3 = success, 4 = failure // $status->stateText - Human-readable (nullable) // $status->no - Payment number (nullable) // $status->token - Reusable token when SAVECARD=1 (nullable) // $status->paidWith - Card details (when saveCard=false) // $status->paymentInstrument - Saved instrument (when saveCard=true)
NoReg redirect callback
After the customer pays, ePay redirects them to your configured REPLY_ADDRESS. The query string looks like:
?ret=authok&authok=1&deviceid=<deviceId>&id=<paymentId>
The authorization flow (ePay account) redirects to the same URL but without an id param. Distinguish the two flows by checking for id:
if (isset($_GET['id'])) { // NoReg card payment: call getNoRegPaymentStatus() to fetch state + token } else { // Auth flow: exchange saved KEY for code, then code for token }
Signing
The SDK signs requests automatically:
- APPCHECK (HMAC-SHA1, sorted params, no trailing newline) — auth flow, user info, registered payments
- CHECKSUM (HMAC-SHA1, sorted params, trailing newline) — noreg create and noreg status
You do not need to compute these yourself.
EasyPay API
EasyPay codes let a customer walk into any EasyPay cash desk in Bulgaria and pay against a 10-digit code. This is a server-to-server call: you post an invoice, you get back an IDN (the code the customer will read at the desk).
Calls <gateway>/ezp/reg_bill.cgi and parses the plain-text CP-1251 response.
Creating an EasyPayClient
use Ux2Dev\Epay\EasyPay\EasyPayClient; use GuzzleHttp\Client; use GuzzleHttp\Psr7\HttpFactory; $factory = new HttpFactory(); $easyPay = new EasyPayClient($config, new Client(), $factory, $factory);
In Laravel:
$easyPay = Epay::easyPay();
Generating a Code
$response = $easyPay->createCode( invoice: 'INV-001', // Required amount: '22.80', // Required. > 0.01 expirationDate: '01.08.2026', // Required. Format: DD.MM.YYYY email: null, // Optional. Merchant email (alternative to MIN) description: 'Monthly fee', // Optional. Max 100 characters encoding: 'utf-8', // Optional. Default: 'utf-8' currency: null, // Optional. Defaults to MerchantConfig::currency ); if ($response->isSuccess()) { // $response->idn - 10-digit code to give the customer // $response->status - Status string returned by ePay // $response->raw - Full raw key/value map from the response } else { // $response->error - Error code (ERR) // $response->errorMessage - Human-readable error (ERRM / MESSAGE) }
The customer then pays the code at any EasyPay cash desk. When the payment clears, ePay.bg sends a regular WEB notification to your EPAY_NOTIFICATION_URL — handle it the same way as any other WEB payment ($web->handleNotification(...)).
Laravel Routes
The SDK ships ready-to-use routes for the three callback types. Enable them in config:
// config/epay.php 'routes' => [ 'enabled' => env('EPAY_ROUTES_ENABLED', false), 'prefix' => env('EPAY_ROUTES_PREFIX', 'epay'), 'middleware' => [], // e.g. ['throttle:60,1'] ],
With enabled = true and the default prefix epay, the following routes are registered:
| Method | URI | Controller | Purpose |
|---|---|---|---|
POST |
/epay/notify |
WebNotificationController |
WEB API payment notifications |
GET |
/epay/billing/init |
BillingController@init |
EasyPay obligation check |
GET |
/epay/billing/confirm |
BillingController@confirm |
EasyPay payment confirmation |
GET |
/epay/callback |
OneTouchCallbackController |
One Touch auth + noreg redirect |
Billing resolvers
The Billing controller can't know about your domain's obligations, so you register closures in a service provider:
use Ux2Dev\Epay\Laravel\EpayFacade as Epay; use Ux2Dev\Epay\Billing\Request\InitRequest; use Ux2Dev\Epay\Billing\Request\ConfirmRequest; use Ux2Dev\Epay\Billing\Response\InitResponse; use Ux2Dev\Epay\Billing\Response\ConfirmResponse; // AppServiceProvider::boot() Epay::billingInitUsing(function (InitRequest $req): InitResponse { $obligations = Obligation::where('idn', $req->idn)->unpaid()->get(); if ($obligations->isEmpty()) { return InitResponse::noObligation($req->idn); } return InitResponse::success( idn: $req->idn, shortDesc: 'Задължения на ' . $req->idn, amount: $obligations->sum('amount'), validTo: now()->addDays(30), ); }); Epay::billingConfirmUsing(function (ConfirmRequest $req): ConfirmResponse { if (Payment::where('tid', $req->tid)->exists()) { return ConfirmResponse::duplicate(); } Payment::recordFromBilling($req); return ConfirmResponse::success(); });
The controller throws LogicException if a request arrives and no resolver is registered — fail loud rather than silently returning empty responses.
Listening to callbacks
Every controller dispatches events; wire them in your EventServiceProvider:
use Ux2Dev\Epay\Laravel\Events\NoRegPaymentCallback; use Ux2Dev\Epay\Laravel\Events\OneTouchAuthorizationCallback; use Ux2Dev\Epay\Laravel\Events\PaymentReceived; protected $listen = [ PaymentReceived::class => [MarkOrderPaid::class], NoRegPaymentCallback::class => [FetchNoRegStatus::class], OneTouchAuthorizationCallback::class => [ExchangeKeyForToken::class], ];
The One Touch callback does not auto-exchange the key for a token — that requires access to the app-stored KEY used when generating the auth URL. Your listener decides what to do:
final class ExchangeKeyForToken { public function handle(OneTouchAuthorizationCallback $event): void { $key = Cache::pull("epay.onetouch.key.{$event->deviceId}"); if ($key === null) return; $oneTouch = Epay::oneTouch(); $code = $oneTouch->getCode($event->deviceId, $key); $token = $oneTouch->getToken($event->deviceId, $code->code); // Persist $token->token for future payments } }
Laravel Events
| Event | Payload | Triggered when |
|---|---|---|
PaymentReceived |
NotificationItem $item, string $merchant |
WEB notification with STATUS=PAID |
PaymentDenied |
NotificationItem $item, string $merchant |
WEB notification with STATUS=DENIED |
PaymentExpired |
NotificationItem $item, string $merchant |
WEB notification with STATUS=EXPIRED |
BillingObligationChecked |
InitRequest $request, string $merchant |
Billing /billing/init processed |
BillingPaymentConfirmed |
ConfirmRequest $request, string $merchant |
Billing /billing/confirm processed |
OneTouchAuthorizationCallback |
string $deviceId, array $params, string $merchant |
One Touch auth redirect (no id param) |
NoRegPaymentCallback |
string $paymentId, string $deviceId, array $params, string $merchant |
One Touch noreg redirect (has id param) |
Error Handling
All SDK exceptions extend Ux2Dev\Epay\Exception\EpayException:
use Ux2Dev\Epay\Exception\EpayException; use Ux2Dev\Epay\Exception\ConfigurationException; use Ux2Dev\Epay\Exception\SigningException; use Ux2Dev\Epay\Exception\InvalidResponseException; try { $result = $web->handleNotification($_POST); } catch (InvalidResponseException $e) { // CHECKSUM verification failed or invalid data // $e->getResponseData() returns the redacted response data error_log('Invalid notification: ' . $e->getMessage()); } catch (ConfigurationException $e) { // Invalid configuration (empty merchant ID, bad amount format, etc.) } catch (SigningException $e) { // Key loading or signing error } catch (EpayException $e) { // Any other ePay error (e.g. One Touch API error response) }
Sensitive fields (CHECKSUM, ENCODED, SIGNATURE, TOKEN) are automatically redacted in exception data.
Testing
Run the test suite:
composer install vendor/bin/pest
Prerequisites for Production
Before going live with ePay.bg:
- Sign a contract with ePay.bg
- Get your KIN (merchant identification number) from your ePay.bg profile
- Get your secret word from your ePay.bg profile (requires phone verification)
- For Billing API: register at
mrcs.easypay.bgand provide your notification URL - For RSA signing: generate a key pair and upload the public key to your profile
- Test everything on
demo.epay.bgfirst
License
MIT