SDKs & Plugins
Official client libraries for Node.js, Python, PHP, Java, and Go — plus NestJS, Django, Flask, FastAPI, Laravel, and Spring Boot guides.
SDKs & Plugins
Every example on this site can be read as raw HTTP or as an official SDK. Use the SDK / HTTP toggle in the corner of any code block to switch — the choice is remembered across the whole site, so pick your language once and the docs follow.
In SDK mode, languages with framework guides get a second row — Node.js / NestJS, Python / Django / Flask / FastAPI, PHP / Laravel, and Java / Spring Boot — and each language remembers its own pick. Links carry the choice too: add ?lang=java&fw=spring to any
page URL to open it straight in the Spring Boot guide.
Available libraries
| Language | Package | Install | Minimum |
|---|---|---|---|
| Node.js, Bun, Deno | @e-pay/node | npm install @e-pay/node | Node 18 |
| NestJS | @e-pay/nestjs | npm install @e-pay/nestjs | Nest 10 |
| Python | epay | pip install epay | Python 3.9 |
| PHP, Laravel | epay-et/php-sdk | composer require epay-et/php-sdk | PHP 8.1 |
| Java | com.epayethiopia:epay-java | implementation("com.epayethiopia:epay-java:0.1.0") | Java 17 |
| Spring Boot | com.epayethiopia:epay-spring-boot-starter | implementation("com.epayethiopia:epay-spring-boot-starter:0.1.0") | Boot 3.5 |
| Go | github.com/epay-et/go-sdk | go get github.com/epay-et/go-sdk | Go 1.22 |
All of them are MIT licensed and track the same API surface: payments,
transactions, paymentProviders, and webhooks.
What the SDK adds
The endpoints, field names, and response shapes are identical either way. What a client library saves you is the part that is easy to get subtly wrong:
| Raw HTTP | SDK | |
|---|---|---|
| Retries | You write the backoff loop | Exponential backoff with jitter on 429, 5xx, and network errors |
| Idempotency | You generate and thread the key yourself | One key reused across every retry of an initialize, so a retry cannot double-charge |
| Validation | The API tells you after a round trip | amount, currencyCode, and customerPhone are checked and normalized locally |
| Amounts | Easy to land on a binary float | Kept exact — Decimal in Python, BigDecimal in Java, strings and minor-unit helpers in Go |
| Pagination | You carry the cursor and re-apply filters | Iterate a page and it walks the rest, lazily, filters intact |
| Webhooks | Hand-rolled HMAC, and one === away from a timing leak | Constant-time verification that fails closed, plus framework handlers |
| Errors | Status codes | A typed class per status, carrying the parsed body and headers |
Nothing is hidden. Every client exposes an escape hatch — epay.request in
Node, Python, and PHP, client.Do in Go — that reaches any endpoint the
library does not wrap yet, with the same auth, timeout, retry, and error
handling.
Quick start
npm install @e-pay/nodenpm install @e-pay/nestjspip install epaypip install epaypip install epaypip install epaycomposer require epay-et/php-sdkcomposer require epay-et/php-sdkimplementation("com.epayethiopia:epay-java:0.1.0")implementation("com.epayethiopia:epay-spring-boot-starter:0.1.0")go get github.com/epay-et/go-sdkNothing NestJS-specific here — this is the same Node.js client call.
Nothing Django-specific here — this is the same Python client call.
Nothing Flask-specific here — this is the same Python client call.
Nothing FastAPI-specific here — this is the same Python client call.
Nothing Laravel-specific here — this is the same PHP client call.
Nothing Spring Boot-specific here — this is the same Java client call.
curl https://api.epayethiopia.com/v1/transactions/initialize \
-X POST \
-H "Authorization: Bearer sk_test_your_key_here" \
-H "Content-Type: application/json" \
-H "x-idempotency-key: order_123" \
-d '{
"amount": "250.00",
"currencyCode": "ETB",
"customerPhone": "+251911234567"
}'import { Epay } from '@e-pay/node';
const epay = new Epay(); // reads EPAY_SECRET_KEY
const session = await epay.payments.initialize({
amount: 250, // a number is formatted to '250.00'
currencyCode: 'etb', // uppercased
customerPhone: '0911234567', // rewritten to '+251911234567'
merchantReference: 'order_123',
});
redirect(session.checkoutUrl);
const res = await fetch('https://api.epayethiopia.com/v1/transactions/initialize', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.EPAY_SECRET_KEY}`,
'Content-Type': 'application/json',
'x-idempotency-key': 'order_123',
},
body: JSON.stringify({
amount: '250.00',
currencyCode: 'ETB',
customerPhone: '+251911234567',
}),
});
const session = await res.json();from decimal import Decimal
from epay import Epay
epay = Epay() # reads EPAY_SECRET_KEY
session = epay.payments.initialize(
amount=Decimal("250.00"), # str, int, Decimal, or float
currency_code="etb", # uppercased
customer_phone="0911234567", # rewritten to '+251911234567'
merchant_reference="order_123",
)
redirect(session["checkoutUrl"])
import os
import requests
res = requests.post(
"https://api.epayethiopia.com/v1/transactions/initialize",
headers={
"Authorization": f"Bearer {os.environ['EPAY_SECRET_KEY']}",
"x-idempotency-key": "order_123",
},
json={
"amount": "250.00",
"currencyCode": "ETB",
"customerPhone": "+251911234567",
},
timeout=30,
)
session = res.json()use Epay\Epay;
$epay = new Epay(); // reads EPAY_SECRET_KEY
$session = $epay->payments->initialize([
'amount' => 250, // string, int, or float
'currencyCode' => 'etb', // uppercased
'customerPhone' => '0911234567', // rewritten to '+251911234567'
'merchantReference' => 'order_123',
]);
header('Location: ' . $session['checkoutUrl']);
$ch = curl_init('https://api.epayethiopia.com/v1/transactions/initialize');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('EPAY_SECRET_KEY'),
'Content-Type: application/json',
'x-idempotency-key: order_123',
],
CURLOPT_POSTFIELDS => json_encode([
'amount' => '250.00',
'currencyCode' => 'ETB',
'customerPhone' => '+251911234567',
]),
]);
$session = json_decode(curl_exec($ch), true);
curl_close($ch);package main
import (
"context"
"log"
epay "github.com/epay-et/go-sdk"
)
func main() {
client, err := epay.NewClient() // reads EPAY_SECRET_KEY
if err != nil {
log.Fatal(err)
}
session, err := client.Payments.Initialize(context.Background(), epay.InitializeParams{
Amount: "250.00",
CurrencyCode: "ETB",
CustomerPhone: "+251911234567",
MerchantReference: "order_123",
})
if err != nil {
log.Fatal(err)
}
log.Println(session.CheckoutURL)
}
payload, _ := json.Marshal(map[string]string{
"amount": "250.00",
"currencyCode": "ETB",
"customerPhone": "+251911234567",
})
req, _ := http.NewRequest(http.MethodPost,
"https://api.epayethiopia.com/v1/transactions/initialize", bytes.NewReader(payload))
req.Header.Set("Authorization", "Bearer "+os.Getenv("EPAY_SECRET_KEY"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-idempotency-key", "order_123")
res, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal(err)
}
defer res.Body.Close()
var session struct {
Reference string `json:"reference"`
CheckoutURL string `json:"checkoutUrl"`
}
json.NewDecoder(res.Body).Decode(&session)import com.epayethiopia.epay.Epay;
import com.epayethiopia.epay.InitializeParams;
import com.epayethiopia.epay.model.InitializedPayment;
Epay epay = Epay.fromEnvironment(); // reads EPAY_SECRET_KEY; thread-safe, share one
InitializedPayment session = epay.payments().initialize(InitializeParams.builder()
.amount(new BigDecimal("250")) // or "250", or .amountMinorUnits(25_000)
.currencyCode("etb") // uppercased
.customerPhone("0911234567") // rewritten to +251911234567
.merchantReference("order_123")
.build()); // validates every field, before any request
response.sendRedirect(session.checkoutUrl());
HttpClient http = HttpClient.newHttpClient();
String body = """
{
"amount": "250.00",
"currencyCode": "ETB",
"customerPhone": "+251911234567"
}
""";
HttpRequest request = HttpRequest.newBuilder(
URI.create("https://api.epayethiopia.com/v1/transactions/initialize"))
.header("Authorization", "Bearer " + System.getenv("EPAY_SECRET_KEY"))
.header("Content-Type", "application/json")
.header("x-idempotency-key", "order_123")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() >= 400) {
throw new IllegalStateException("ePay " + response.statusCode() + ": " + response.body());
}
// {"reference":"…","checkoutUrl":"…","status":"success","expiresAt":"…"}
String session = response.body();// app.module.ts, once: EpayModule.forRoot({ isGlobal: true }) reads EPAY_SECRET_KEY
import { Controller, Param, Post, Redirect } from '@nestjs/common';
import { EpayService } from '@e-pay/nestjs';
@Controller('orders')
export class CheckoutController {
constructor(
private readonly epay: EpayService,
private readonly orders: OrdersService,
) {}
@Post(':id/pay')
@Redirect()
async pay(@Param('id') id: string) {
const order = await this.orders.find(id);
const session = await this.epay.payments.initialize(
{
amount: order.total,
currencyCode: 'ETB',
customerPhone: order.customerPhone,
merchantReference: order.id,
returnUrl: `https://yourstore.com/orders/${order.id}/complete`,
},
{ idempotencyKey: order.id },
);
return { url: session.checkoutUrl, statusCode: 303 };
}
}
# payments/clients.py — one client per process; it pools connections
from django.conf import settings
from epay import Epay
epay = Epay(api_key=settings.EPAY_SECRET_KEY)
# payments/views.py
from django.shortcuts import get_object_or_404, redirect
from django.views.decorators.http import require_POST
from .clients import epay
from .models import Order
@require_POST
def pay(request, order_id):
order = get_object_or_404(Order, pk=order_id)
session = epay.payments.initialize(
amount=order.total, # a DecimalField stays exact
currency_code="ETB",
customer_phone=order.customer_phone,
merchant_reference=str(order.pk),
return_url=request.build_absolute_uri(f"/orders/{order.pk}/complete"),
idempotency_key=str(order.pk),
)
return redirect(session["checkoutUrl"])from flask import Flask, redirect, url_for
from epay import Epay
app = Flask(**name**)
epay = Epay() # one client per process; reads EPAY_SECRET_KEY
@app.post("/orders/<order_id>/pay")
def pay(order_id):
order = Order.query.get_or_404(order_id)
session = epay.payments.initialize(
amount=order.total,
currency_code="ETB",
customer_phone=order.customer_phone,
merchant_reference=order_id,
return_url=url_for("order_complete", order_id=order_id, _external=True),
idempotency_key=order_id,
)
return redirect(session["checkoutUrl"], code=303)
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.responses import RedirectResponse
from epay import AsyncEpay
epay = AsyncEpay() # reads EPAY_SECRET_KEY
@asynccontextmanager
async def lifespan(app: FastAPI):
yield
await epay.aclose() # release the connection pool on shutdown
app = FastAPI(lifespan=lifespan)
@app.post("/orders/{order_id}/pay")
async def pay(order_id: str):
order = await orders.get(order_id)
session = await epay.payments.initialize(
amount=order.total,
currency_code="ETB",
customer_phone=order.customer_phone,
merchant_reference=order_id,
return_url=f"https://yourstore.com/orders/{order_id}/complete",
idempotency_key=order_id,
)
return RedirectResponse(session["checkoutUrl"], status_code=303)// The service provider is auto-discovered and reads EPAY_SECRET_KEY from .env.
use App\Models\Order;
use Epay\Epay;
use Illuminate\Http\RedirectResponse;
final class CheckoutController
{
public function \_\_construct(private readonly Epay $epay) {}
public function store(Order $order): RedirectResponse
{
$session = $this->epay->payments->initialize([
'amount' => $order->total,
'currencyCode' => 'ETB',
'customerPhone' => $order->customer_phone,
'merchantReference' => (string) $order->id,
'returnUrl' => route('orders.complete', $order),
], idempotencyKey: (string) $order->id);
return redirect()->away($session['checkoutUrl']);
}
}
// routes/web.php
// Route::post('/orders/{order}/pay', [CheckoutController::class, 'store']);
// application.yml
// epay:
// api-key: ${EPAY_SECRET_KEY}
@RestController
class CheckoutController {
private final Epay epay;
private final Orders orders;
CheckoutController(Epay epay, Orders orders) {
this.epay = epay;
this.orders = orders;
}
@PostMapping("/orders/{id}/pay")
ResponseEntity<Void> pay(@PathVariable String id) {
Order order = orders.find(id);
InitializedPayment session = epay.payments().initialize(InitializeParams.builder()
.amount(order.total())
.currencyCode("ETB")
.customerPhone(order.phone())
.merchantReference(order.id())
.returnUrl("https://yourstore.com/orders/" + order.id() + "/complete")
.idempotencyKey(order.id())
.build());
return ResponseEntity.status(HttpStatus.SEE_OTHER)
.location(URI.create(session.checkoutUrl()))
.build();
}
}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. Read
it back with epay.mode / epay.isSandbox (client.Mode() /
client.IsSandbox() in Go).
Configuration
Every option falls back to an environment variable, so the common case is a bare constructor.
EPAY_SECRET_KEY=sk_test_your_key_here
EPAY_WEBHOOK_SECRET=your_webhook_secret| 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 seconds per attempt |
| Max retries | — | 2 |
| HTTP client | — | fetch (Node), httpx (Python), PSR-18 (PHP), java.net.http (Java), net/http (Go) |
Printing a client masks the key, so a client is safe to log.
In Go, WithTimeout applies per attempt. Setting Timeout on a custom
*http.Client instead bounds the whole call including retries, which is
usually not what you want.
Framework integrations
NestJS
@e-pay/nestjs wraps the Node client in a dynamic module, an injectable
service, a webhook guard, and a param decorator.
import { Module } from '@nestjs/common';
import { EpayModule } from '@e-pay/nestjs';
@Module({
imports: [
EpayModule.forRoot({
apiKey: process.env.EPAY_SECRET_KEY,
webhookSecret: process.env.EPAY_WEBHOOK_SECRET,
isGlobal: true, // inject EpayService anywhere without re-importing
}),
],
})
export class AppModule {}An invalid key fails at bootstrap rather than at the first payment.
forRootAsync accepts useFactory, useClass, and useExisting if you
configure through ConfigService.
Signature verification needs the raw body, so create the app with
NestFactory.create(AppModule, { rawBody: true }), then guard the route:
@Post('epay')
@HttpCode(200)
@UseGuards(EpayWebhookGuard)
async handle(@EpayEvent() event: WebhookEvent) {
// The signature is already verified; an invalid one never reaches here.
await this.queue.enqueue('epay-event', event);
}Laravel
The service provider is auto-discovered. Add your keys to .env and inject
Epay anywhere:
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']);
}
}ePay sends no CSRF token, so exclude the webhook route and alias the middleware
in bootstrap/app.php (Laravel 11+):
->withMiddleware(function (Middleware $middleware) {
$middleware->validateCsrfTokens(except: ['webhooks/epay']);
$middleware->alias(['epay.webhook' => VerifyEpayWebhook::class]);
})Then VerifyEpayWebhook::event($request) hands you a verified event, and throws
rather than returning an unverified payload if the middleware was not applied.
Express, Next.js, Hono, Bun, Deno, Cloudflare Workers
// Express — mount before any global express.json()
import { expressEpayWebhook } from '@e-pay/node/express';
// Next.js route handler, Hono, Workers — verifies with WebCrypto,
// imports nothing from node:, runs unchanged on the edge
import { createEpayWebhookHandler } from '@e-pay/node/fetch';
export const POST = createEpayWebhookHandler({
secret: process.env.EPAY_WEBHOOK_SECRET!,
onEvent: async (event) => queue.enqueue(event),
});Flask, Django, FastAPI
One helper per framework, all raising EpayWebhookSignatureError on a bad
signature:
from epay.integrations.flask import construct_event_from_request # Flask
from epay.integrations.django import construct_event_from_request # Django
from epay.integrations.asgi import construct_event_from_request # FastAPITake the whole request object, not a parsed model — a re-serialized body has a
different digest and a valid delivery would be rejected. On Django, remember
@csrf_exempt.
Spring Boot
epay-spring-boot-starter auto-configures a shared Epay bean from epay.*
properties, with IDE completion, and adds a verified @EpayWebhook controller
parameter. It supports Spring Boot 3.5 and 4.
epay:
api-key: ${EPAY_SECRET_KEY}
webhook-secret: ${EPAY_WEBHOOK_SECRET}
# timeout: 30s
# max-retries: 2A missing or invalid key stops the application at startup with a message saying
what to set; epay.enabled=false starts without the client, for tests that do
not need it. Declare an EpayClientCustomizer bean to adjust the client, or
your own Epay bean to replace it.
@EpayWebhook checks the signature against the raw request bytes before your
method runs — 401 on a bad signature, 400 on a body that is not a JSON
object. Do not add @RequestBody to the same method; it consumes the body
before it can be verified. It supports Spring MVC; in WebFlux, read the body as
a byte[] and call epay.webhooks().constructEvent(…) yourself.
Go net/http
verifier := epay.NewWebhookVerifier(os.Getenv("EPAY_WEBHOOK_SECRET"))
mux.Handle("/webhooks/epay", verifier.Handler(func(event *epay.WebhookEvent) error {
return queue.Enqueue(event)
}))Errors
Every failure is a typed error carrying the status, parsed body, and response
headers. 429, 5xx, and network errors are retried automatically first, so
seeing one means the retry budget was also exhausted.
| Raised on | Node.js / NestJS | Python | PHP | Java | Go |
|---|---|---|---|---|---|
| Local validation, no request sent | EpayValidationError | EpayValidationError | EpayValidationException | EpayValidationException | ErrValidation |
| Unusable client options | EpayConfigError | EpayConfigError | EpayConfigException | EpayConfigException | ErrConfig |
400 | EpayBadRequestError | EpayBadRequestError | EpayBadRequestException | EpayBadRequestException | ErrBadRequest |
401 | EpayAuthenticationError | EpayAuthenticationError | EpayAuthenticationException | EpayAuthenticationException | ErrAuthentication |
403 | EpayPermissionDeniedError | EpayPermissionDeniedError | EpayPermissionDeniedException | EpayPermissionDeniedException | ErrPermissionDenied |
404 | EpayNotFoundError | EpayNotFoundError | EpayNotFoundException | EpayNotFoundException | ErrNotFound |
409 | EpayConflictError | EpayConflictError | EpayConflictException | EpayConflictException | ErrConflict |
429 | EpayRateLimitError | EpayRateLimitError | EpayRateLimitException | EpayRateLimitException | ErrRateLimit |
5xx | EpayServerError | EpayServerError | EpayServerException | EpayServerException | ErrServer |
| Attempt exceeded the timeout | EpayTimeoutError | EpayTimeoutError | EpayTimeoutException | EpayTimeoutException | ErrTimeout |
| No response at all | EpayConnectionError | EpayConnectionError | EpayConnectionException | EpayConnectionException | ErrConnection |
| Bad webhook signature | EpayWebhookSignatureError | EpayWebhookSignatureError | EpayWebhookSignatureException | EpayWebhookSignatureException | ErrWebhookSignature |
429 exposes the retry-after — in seconds, or as an Optional<Duration> from
retryAfter() in Java. In Go the sentinels are matched with
errors.Is, and a cancelled caller context comes back as context.Canceled
unwrapped, so it is never mistaken for an SDK timeout.
See Error Codes for what each status means on the wire.
Testing
Point the client at a stub and no request leaves the process:
const epay = new Epay({
apiKey: 'sk_test_fake',
maxRetries: 0,
fetch: async () =>
new Response(JSON.stringify({ reference: 'PAB1', checkoutUrl: '…' }), {
status: 200,
}),
});Against the real sandbox, use a sk_test_… key with the magic phone numbers in
Test Accounts — and generate a fresh
idempotency key per run, or you will get the cached response instead of the
scenario.
Next: Quick Start — install a client and take your first payment.