PHPackages                             ux2dev/epay-easypay - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Payment Processing](/categories/payments)
4. /
5. ux2dev/epay-easypay

ActiveLibrary[Payment Processing](/categories/payments)

ux2dev/epay-easypay
===================

PHP SDK for ePay.bg / EasyPay payment gateway (WEB API, One Touch, Billing)

v0.3.0(3mo ago)1205↑266.7%MITPHPPHP ^8.2

Since Apr 15Pushed 3mo ago1 watchersCompare

[ Source](https://github.com/ux2dev/epay)[ Packagist](https://packagist.org/packages/ux2dev/epay-easypay)[ RSS](/packages/ux2dev-epay-easypay/feed)WikiDiscussions main Synced 3w ago

READMEChangelogDependencies (5)Versions (4)Used By (0)

ePay.bg / EasyPay PHP SDK
=========================

[](#epaybg--easypay-php-sdk)

> **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
------------

[](#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/guzzle` transitively, which provides both)

Installation
------------

[](#installation)

```
composer require ux2dev/epay-easypay
```

Quick Start
-----------

[](#quick-start)

### Plain PHP

[](#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 '';
foreach ($request->toArray() as $name => $value) {
    echo '';
}
echo 'Pay with ePay.bg';
echo '';
```

### Laravel

[](#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
-------------

[](#configuration)

### MerchantConfig

[](#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:**

EnvironmentGateway URLOne 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

[](#enums)

All enums live under `Ux2Dev\Epay\Enum\` (or `Ux2Dev\Epay\Billing\Enum\` for Billing-specific ones):

EnumCasesUsed 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

[](#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)

[](#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:

MethodReturnsPurpose`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
-------

[](#web-api)

The WEB API handles browser-based payments. The flow is:

1. Your server creates a payment request with signed data
2. You render an HTML form that POSTs to ePay.bg
3. The customer pays on ePay.bg
4. ePay.bg sends a callback (notification) to your server
5. ePay.bg redirects the customer back to your site

### Creating a WebClient

[](#creating-a-webclient)

```
use Ux2Dev\Epay\Web\WebClient;

$web = new WebClient($config);
```

In Laravel:

```
$web = Epay::web();
```

### Payment Request (Standard)

[](#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:

```

    @foreach($formFields as $name => $value)

    @endforeach
    Pay Now

```

### Direct Card Payment Request

[](#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

[](#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

[](#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)

[](#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:**

MethodePay StatusMeaning`$item->acknowledge()`OKReceived successfully. ePay stops sending.`$item->reject()`ERRError processing. ePay will retry.`$item->notFound()`NOUnknown 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)

[](#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
-----------

[](#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.

1. A customer goes to EasyPay or ePay.bg and enters their subscriber number (IDN)
2. ePay.bg calls your `/pay/init` endpoint: "How much does subscriber X owe?"
3. Your server responds with the obligation amount
4. The customer pays
5. ePay.bg calls your `/pay/confirm` endpoint: "Subscriber X paid"
6. Your server confirms

### Creating a BillingHandler

[](#creating-a-billinghandler)

```
use Ux2Dev\Epay\Billing\BillingHandler;

$billing = new BillingHandler($config);
```

In Laravel:

```
$billing = Epay::billing();
```

### Handling /pay/init (Obligation Check)

[](#handling-payinit-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:**

MethodSTATUSWhen to use`InitResponse::success(...)`00Subscriber found, has obligations`InitResponse::noObligation($idn)`62Subscriber found, no obligations`InitResponse::invalidSubscriber($idn)`14Unknown subscriber`InitResponse::invalidAmount()`13Invalid deposit amount`InitResponse::unavailable()`80Temporarily unavailable`InitResponse::error()`96General error### Handling /pay/confirm (Payment Confirmation)

[](#handling-payconfirm-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:**

MethodSTATUSWhen to use`ConfirmResponse::success()`00Payment recorded`ConfirmResponse::duplicate()`94Already processed (same TID)`ConfirmResponse::invalidChecksum()`93Bad checksum`ConfirmResponse::error()`96General error### CHECKSUM Calculation (Billing)

[](#checksum-calculation-billing)

The Billing API uses a different CHECKSUM algorithm than the WEB API. The SDK handles this automatically, but for reference:

1. Collect all GET parameters except CHECKSUM
2. Sort alphabetically by parameter name
3. Concatenate as `KEY1VALUE1\nKEY2VALUE2\n...\n` — no separator between key and value, `\n` between pairs, **and a trailing `\n` after the last pair**
4. 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

[](#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

[](#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

[](#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
-------------

[](#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

[](#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)

[](#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

[](#token-management)

```
// Revoke a token
$oneTouch->invalidateToken(
    deviceId: 'user@example.com',
    token: $savedToken,
);
```

### User Information

[](#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)

[](#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

[](#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

[](#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=&id=

```

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

[](#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-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 `/ezp/reg_bill.cgi` and parses the plain-text CP-1251 response.

### Creating an EasyPayClient

[](#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

[](#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
--------------

[](#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:

MethodURIControllerPurpose`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

[](#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

[](#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
--------------

[](#laravel-events)

EventPayloadTriggered 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
--------------

[](#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
-------

[](#testing)

Run the test suite:

```
composer install
vendor/bin/pest
```

Prerequisites for Production
----------------------------

[](#prerequisites-for-production)

Before going live with ePay.bg:

1. Sign a contract with ePay.bg
2. Get your KIN (merchant identification number) from your ePay.bg profile
3. Get your secret word from your ePay.bg profile (requires phone verification)
4. For Billing API: register at `mrcs.easypay.bg` and provide your notification URL
5. For RSA signing: generate a key pair and upload the public key to your profile
6. Test everything on `demo.epay.bg` first

License
-------

[](#license)

MIT

###  Health Score

38

—

LowBetter than 83% of packages

Maintenance82

Actively maintained with recent releases

Popularity17

Limited adoption so far

Community7

Small or concentrated contributor base

Maturity38

Early-stage or recently created project

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~2 days

Total

3

Last Release

97d ago

### Community

Maintainers

![](https://avatars.githubusercontent.com/u/181749481?v=4)[ux2dev](/maintainers/ux2dev)[@ux2dev](https://github.com/ux2dev)

---

Top Contributors

[![uxperience-hlaskov](https://avatars.githubusercontent.com/u/88424258?v=4)](https://github.com/uxperience-hlaskov "uxperience-hlaskov (5 commits)")

###  Code Quality

TestsPest

### Embed Badge

![Health badge](/badges/ux2dev-epay-easypay/health.svg)

```
[![Health](https://phpackages.com/badges/ux2dev-epay-easypay/health.svg)](https://phpackages.com/packages/ux2dev-epay-easypay)
```

###  Alternatives

[tempest/framework

The PHP framework that gets out of your way.

2.2k34.4k16](/packages/tempest-framework)[chargebee/chargebee-php

ChargeBee API client implementation for PHP

758.5M9](/packages/chargebee-chargebee-php)[flow-php/flow

PHP ETL - Extract Transform Load - Data processing framework

85036.3k](/packages/flow-php-flow)[drupal/core-recommended

Locked core dependencies; require this project INSTEAD OF drupal/core.

6942.5M425](/packages/drupal-core-recommended)[laudis/neo4j-php-client

Neo4j-PHP-Client is the most advanced PHP Client for Neo4j

185702.8k44](/packages/laudis-neo4j-php-client)[telnyx/telnyx-php

Official Telnyx PHP SDK — APIs for Voice, SMS, MMS, WhatsApp, Fax, SIP Trunking, Wireless IoT, Call Control, and more. Build global communications on Telnyx's private carrier-grade network.

36789.4k2](/packages/telnyx-telnyx-php)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
