epay-et / php-sdk
Official ePay Business API client for PHP, with a Laravel integration.
Requires
- php: ^8.1
- ext-json: *
- guzzlehttp/guzzle: ^7.5
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.50
- illuminate/http: ^10.0|^11.0
- illuminate/support: ^10.0|^11.0
- phpstan/phpstan: ^2.2
- phpunit/phpunit: ^10.5
Suggests
- illuminate/support: Required to use the Laravel service provider and middleware (^10.0|^11.0).
Provides
None
Conflicts
None
Replaces
None
README
Official ePay Business API client for PHP, with a Laravel integration.
Accept payments from every major Ethiopian mobile wallet and bank with one integration.
Full API documentation: https://docs.epayethiopia.com/
- PHP 8.1+, typed throughout, PSR-4 and PSR-18
- Automatic retries with exponential backoff and jitter on
429/5xx/network errors - Safe retries on initialize — every attempt reuses one idempotency key
- Exact decimal amounts via bcmath, with a string-comparison fallback
- Constant-time webhook verification with
hash_equals - Laravel: auto-discovered service provider, publishable config, webhook middleware
Install
composer require epay-et/php-sdk
Quick start
use Epay\Epay; $epay = new Epay(['api_key' => getenv('EPAY_SECRET_KEY')]); $session = $epay->payments->initialize([ 'amount' => '250.00', 'currencyCode' => 'ETB', 'customerPhone' => '+251911234567', 'merchantReference' => 'order_123', 'returnUrl' => 'https://yourstore.com/orders/123/complete', ], idempotencyKey: 'order_123'); // Save $session['reference'], then send the customer to pay. header('Location: ' . $session['checkoutUrl']);
The key prefix picks the environment: sk_test_… runs against the sandbox and
sk_live_… moves real money. There is no separate mode to configure.
Configuration
Every option falls back to an environment variable, so new Epay() is enough.
| Option | Environment variable | Default |
|---|---|---|
api_key |
EPAY_SECRET_KEY |
required |
webhook_secret |
EPAY_WEBHOOK_SECRET |
— |
base_url |
EPAY_BASE_URL |
https://api.epayethiopia.com/v1 |
timeout |
— | 30.0 seconds per attempt |
max_retries |
— | 2 |
default_headers |
— | [] |
$epay = new Epay(['timeout' => 10.0, 'max_retries' => 3]); $epay->mode(); // 'live' | 'sandbox' | null $epay->isSandbox(); // bool
(string) $epay and var_dump($epay) both mask the key, so a client is safe to log.
Bring your own HTTP client
The second constructor argument takes any PSR-18 client — for connection pooling, an outbound proxy, or mTLS:
use GuzzleHttp\Client as GuzzleClient; $http = new GuzzleClient([ 'proxy' => 'http://proxy.internal:8080', 'http_errors' => false, // let the SDK map statuses and retry ]); $epay = new Epay(['api_key' => $key], $http);
Leave http_errors off: the SDK maps non-2xx responses itself so the retry
policy and error hierarchy stay in one place.
Payments
Initialize
$session = $epay->payments->initialize([ 'amount' => 250, // string, int, or float 'currencyCode' => 'etb', // uppercased 'customerPhone' => '0911234567', // rewritten to '+251911234567' 'merchantReference' => 'order_123', 'email' => 'abebe@example.com', 'callbackUrl' => 'https://yourstore.com/webhooks/epay', // overrides the dashboard URL ], idempotencyKey: 'order_123');
amount, currencyCode, and customerPhone are validated locally, so a typo
throws EpayValidationException immediately instead of costing a round trip.
On amounts. Pass a string. Integers and floats are accepted and rounded half-up to two places, but floats cannot represent every decimal amount exactly. The result always carries two decimal places.
On idempotency keys. If you omit idempotencyKey, the SDK generates a fresh
one per call. That makes its internal retries safe — a retried initialize
cannot double-charge — but it does not deduplicate across separate calls. Pass
your own order id to get that guarantee.
Verify before fulfilling
$receipt = $epay->payments->verify($reference); // $receipt['status'] === 'completed', plus serviceFee, paymentMethod, customer
The API rejects a transaction that is not yet completed, so use
transactions->retrieve first if you would rather branch on status than catch
an EpayBadRequestException.
Cancel
$epay->payments->cancel($reference); // returns void on 204
Only pending and processing transactions can be cancelled, and cancellation
is irreversible. This call is never retried automatically, because the endpoint
takes no idempotency key.
Receipt for any outcome
$receipt = $epay->payments->receipt($reference); // $receipt['status'] is 'completed', 'failed', or 'cancelled'
Unlike verify, receipt accepts failed and cancelled transactions too, which
suits records, audits, and customer-facing receipts. It still throws
EpayBadRequestException for a transaction that is pending or processing.
Direct payment
Charge a customer's wallet or bank account without redirecting them anywhere. ePay pushes an authorization prompt (USSD menu, SMS OTP, or in-app notification) to their phone:
$charge = $epay->payments->directPayment([ 'amount' => '250.00', 'currencyCode' => 'ETB', 'customerPhone' => '0911234567', 'pspCode' => 'TELEBIRR', 'merchantReference' => 'order_456', ], idempotencyKey: 'order_456'); // $charge['status'] === 'pending'; store $charge['reference']
The call returns as soon as the prompt is sent. The outcome arrives as a
payment.success or payment.failed webhook once the customer approves or
declines, so fulfil the order from your webhook handler, not from this response.
Choose pspCode, and optionally flowCode, from the providers that support it:
$providers = $epay->paymentProviders->listForDirectPayment(); // [['providerCode' => 'TELEBIRR', 'flows' => [['flowCode' => 'USSD', 'isDefault' => true, ...]]]]
Like initialize, a direct payment is retried safely with one idempotency key.
Pass your order id so a double-submitted form cannot charge twice. Direct payments
must be enabled for your account; contact ePay support.
SMS payment link
Text the customer a link to the hosted checkout page, for invoices, bills, and customers who are not present:
$link = $epay->payments->sendSmsLink([ 'customerPhone' => '0911234567', 'currencyCode' => 'ETB', 'amount' => '500.00', // omit to let the customer enter the amount ]);
An amount is signed into the link, so it cannot be tampered with. The link expires after 1 hour. This call is never retried automatically, so a customer is never texted twice.
Transactions
$transaction = $epay->transactions->retrieve($reference); $timeline = $epay->transactions->timeline($reference); foreach ($timeline['events'] as $event) { echo $event['eventType'], ' ', $event['occurredAt'], PHP_EOL; }
Listing and pagination
The endpoint is cursor-paginated at a fixed 10 per page. Iterate a page to walk every following page, fetching lazily and carrying your filters along:
foreach ($epay->transactions->list([ 'status' => 'completed', 'currency' => 'ETB', 'from' => '2026-08-01', 'to' => '2026-08-31', ]) as $transaction) { echo $transaction['reference'], ' ', $transaction['amount'], PHP_EOL; }
from and to accept a string or any DateTimeInterface, and the API caps the
range at 90 days.
Other ways to consume the same endpoint:
$page = $epay->transactions->list(); $page->data(); // exactly this page, no extra requests $page->hasMore(); // bool $page->nextPage(); // the next TransactionPage, or null $recent = $epay->transactions->list()->toArray(50); // cap the walk foreach ($epay->transactions->list()->pages() as $page) { // page at a time processBatch($page->data()); }
Payment providers
$epay->paymentProviders->getAll(); // every provider, with its flows $epay->paymentProviders->listForDirectPayment(); // only what directPayment accepts $epay->paymentProviders->list(); // compact, for dropdowns
Each provider lists its flows. A flow's isDefault marks the one to
pre-select, and supportsDirectPayment marks the ones directPayment accepts.
The endpoints are mode-aware and permission-gated: getAll needs
get_platform_payment_provider, and list needs list_platform_payment_provider.
listEnabled() is deprecated. The API no longer reports a per-provider enabled
state, so it now returns the same list as getAll().
Webhooks
Verify the X-Epay-Signature header against the raw request body before you
trust a payload. Re-encoding a decoded array can reorder keys and change the
digest, which rejects valid deliveries.
use Epay\Exception\EpayWebhookSignatureException; $raw = file_get_contents('php://input'); $signature = $_SERVER['HTTP_X_EPAY_SIGNATURE'] ?? null; try { $event = $epay->webhooks->constructEvent($raw, $signature); } catch (EpayWebhookSignatureException $error) { http_response_code(401); exit; } // Acknowledge fast, process later. enqueue($event); http_response_code(200);
constructEvent fails closed: any event it returns had a valid signature.
Handling events
match ($event['event']) { 'payment.success' => fulfil($event['reference']), 'payment.failed', 'payment.cancelled' => release($event['reference']), 'payment.refunding', 'payment.refunded', 'payment.reversed' => reconcile($event), default => null, // an event this SDK version does not know yet };
Direct payment events also carry pspCode, customerPhone, and occurredAt, and
payment.failed carries a reason.
ePay retries anything that is not a 2xx within 10 seconds, up to 5 attempts,
so keep the handler fast and make it idempotent — deduplicate on
$event['reference'].
Laravel
The service provider is auto-discovered. Add your keys to .env:
EPAY_SECRET_KEY=sk_test_your_key_here EPAY_WEBHOOK_SECRET=your_webhook_secret
Then inject the client anywhere:
use Epay\Epay; final class CheckoutController { public function __construct(private readonly Epay $epay) {} public function store(Request $request) { $session = $this->epay->payments->initialize([ 'amount' => $request->string('amount')->value(), 'currencyCode' => 'ETB', 'customerPhone' => $request->string('phone')->value(), 'merchantReference' => $order->id, ], idempotencyKey: $order->id); return redirect()->away($session['checkoutUrl']); } }
Publish the config to change defaults:
php artisan vendor:publish --tag=epay-config
Webhook middleware
ePay sends no CSRF token, so exclude the route and alias the middleware. In
bootstrap/app.php (Laravel 11+):
use Epay\Laravel\VerifyEpayWebhook; ->withMiddleware(function (Middleware $middleware) { $middleware->validateCsrfTokens(except: ['webhooks/epay']); $middleware->alias(['epay.webhook' => VerifyEpayWebhook::class]); })
On Laravel 10, add 'webhooks/epay' to $except in
app/Http/Middleware/VerifyCsrfToken.php and register the alias in
app/Http/Kernel.php.
Then the route:
use Epay\Laravel\VerifyEpayWebhook; Route::post('/webhooks/epay', function (Request $request) { $event = VerifyEpayWebhook::event($request); ProcessEpayEvent::dispatch($event); // acknowledge fast, process later return response()->noContent(200); })->middleware('epay.webhook');
The middleware rejects a bad or missing signature with 401 before your route
runs, and VerifyEpayWebhook::event() throws rather than returning an
unverified payload if the middleware was not applied.
Error handling
Every failure extends EpayException. HTTP failures carry the status, parsed
body, and response headers.
use Epay\Exception\EpayApiException; use Epay\Exception\EpayBadRequestException; use Epay\Exception\EpayNotFoundException; use Epay\Exception\EpayRateLimitException; try { $receipt = $epay->payments->verify($reference); } catch (EpayNotFoundException) { return null; } catch (EpayBadRequestException) { return 'not settled yet'; } catch (EpayRateLimitException $error) { sleep((int) ($error->retryAfterSeconds() ?? 1)); throw $error; } catch (EpayApiException $error) { Log::error('ePay failure', [ 'status' => $error->status, 'request' => $error->request, 'body' => $error->body, ]); throw $error; }
| Class | Thrown when |
|---|---|
EpayValidationException |
A value failed local validation; no request was sent |
EpayConfigException |
The client was constructed with unusable options |
EpayBadRequestException |
400 |
EpayAuthenticationException |
401 — key missing, invalid, or revoked |
EpayPermissionDeniedException |
403 — IP not whitelisted, or key lacks a permission |
EpayNotFoundException |
404 |
EpayConflictException |
409 |
EpayRateLimitException |
429 — exposes retryAfterSeconds() |
EpayServerException |
5xx |
EpayTimeoutException |
The attempt exceeded timeout |
EpayConnectionException |
No response was received at all |
EpayWebhookSignatureException |
A webhook signature was missing or wrong |
429, 5xx, and network errors are retried automatically before surfacing.
Sandbox testing
Use a sk_test_… key with the documented magic phone numbers:
| Phone | OTP | Outcome |
|---|---|---|
251900000000 |
000111 |
Generic sandbox account |
251900000001 |
123456 |
Completes, fires payment.success |
251900000002 |
654321 |
INVALID_OTP |
251900000003 |
111111 |
OTP_EXPIRED |
251900000004 |
— | Declined, fires payment.failed |
Generate a fresh idempotency key per test run: reusing one returns the cached response instead of triggering the scenario again.
Testing your own code
Pass a scripted PSR-18 client and no request leaves the process — see
tests/ClientTest.php for a ready-made ScriptedHttpClient.
Unmodelled endpoints
$epay->request() reaches anything this version does not wrap yet, with the same
auth, timeout, retry, and error handling:
$data = $epay->request('GET', '/some/new/endpoint', ['limit' => 10]);
Development
composer install composer test # phpunit composer analyse # phpstan, level 8 composer format # php-cs-fixer composer format:check # php-cs-fixer, dry run with a diff
Releasing
CI runs the suite on PHP 8.1 through 8.4 — including a lowest dependency
resolution on 8.1, so the declared minimum constraints are proven to work, not
just the newest releases — plus PHPStan level 8, php-cs-fixer, and
composer validate --strict.
One-time setup:
- Submit the repository at packagist.org/packages/submit.
- Enable the Packagist GitHub integration so new tags publish automatically (Packagist → your profile → Settings, or the repo's webhook settings).
Optional fallback, only if you do not set up that integration: add the
repository secrets PACKAGIST_USERNAME and PACKAGIST_TOKEN. The release
workflow then pings the Packagist API itself, and skips that step when the
secrets are absent.
To release:
git tag v0.2.0 && git push origin v0.2.0
Packagist derives the version from the tag — there is no build step and nothing
to upload. release.yml therefore runs the full check suite on the tag as a
gate, because a published version cannot be withdrawn.
Note that Composer reads composer.json from the repository root, which is
why this SDK lives in its own repository rather than a subdirectory.
License
MIT