Reference

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

LanguagePackageInstallMinimum
Node.js, Bun, Deno@e-pay/nodenpm install @e-pay/nodeNode 18
NestJS@e-pay/nestjsnpm install @e-pay/nestjsNest 10
Pythonepaypip install epayPython 3.9
PHP, Laravelepay-et/php-sdkcomposer require epay-et/php-sdkPHP 8.1
Javacom.epayethiopia:epay-javaimplementation("com.epayethiopia:epay-java:0.1.0")Java 17
Spring Bootcom.epayethiopia:epay-spring-boot-starterimplementation("com.epayethiopia:epay-spring-boot-starter:0.1.0")Boot 3.5
Gogithub.com/epay-et/go-sdkgo get github.com/epay-et/go-sdkGo 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 HTTPSDK
RetriesYou write the backoff loopExponential backoff with jitter on 429, 5xx, and network errors
IdempotencyYou generate and thread the key yourselfOne key reused across every retry of an initialize, so a retry cannot double-charge
ValidationThe API tells you after a round tripamount, currencyCode, and customerPhone are checked and normalized locally
AmountsEasy to land on a binary floatKept exact — Decimal in Python, BigDecimal in Java, strings and minor-unit helpers in Go
PaginationYou carry the cursor and re-apply filtersIterate a page and it walks the rest, lazily, filters intact
WebhooksHand-rolled HMAC, and one === away from a timing leakConstant-time verification that fails closed, plus framework handlers
ErrorsStatus codesA 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

raw HTTP
npm install @e-pay/node
npm install @e-pay/nestjs
pip install epay
pip install epay
pip install epay
pip install epay
composer require epay-et/php-sdk
composer require epay-et/php-sdk
implementation("com.epayethiopia:epay-java:0.1.0")
implementation("com.epayethiopia:epay-spring-boot-starter:0.1.0")
go get github.com/epay-et/go-sdk

Nothing 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
OptionEnvironment variableDefault
API keyEPAY_SECRET_KEYrequired
Webhook secretEPAY_WEBHOOK_SECRET—
Base URLEPAY_BASE_URLhttps://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    # FastAPI

Take 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: 2

A 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 onNode.js / NestJSPythonPHPJavaGo
Local validation, no request sentEpayValidationErrorEpayValidationErrorEpayValidationExceptionEpayValidationExceptionErrValidation
Unusable client optionsEpayConfigErrorEpayConfigErrorEpayConfigExceptionEpayConfigExceptionErrConfig
400EpayBadRequestErrorEpayBadRequestErrorEpayBadRequestExceptionEpayBadRequestExceptionErrBadRequest
401EpayAuthenticationErrorEpayAuthenticationErrorEpayAuthenticationExceptionEpayAuthenticationExceptionErrAuthentication
403EpayPermissionDeniedErrorEpayPermissionDeniedErrorEpayPermissionDeniedExceptionEpayPermissionDeniedExceptionErrPermissionDenied
404EpayNotFoundErrorEpayNotFoundErrorEpayNotFoundExceptionEpayNotFoundExceptionErrNotFound
409EpayConflictErrorEpayConflictErrorEpayConflictExceptionEpayConflictExceptionErrConflict
429EpayRateLimitErrorEpayRateLimitErrorEpayRateLimitExceptionEpayRateLimitExceptionErrRateLimit
5xxEpayServerErrorEpayServerErrorEpayServerExceptionEpayServerExceptionErrServer
Attempt exceeded the timeoutEpayTimeoutErrorEpayTimeoutErrorEpayTimeoutExceptionEpayTimeoutExceptionErrTimeout
No response at allEpayConnectionErrorEpayConnectionErrorEpayConnectionExceptionEpayConnectionExceptionErrConnection
Bad webhook signatureEpayWebhookSignatureErrorEpayWebhookSignatureErrorEpayWebhookSignatureExceptionEpayWebhookSignatureExceptionErrWebhookSignature

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.

On this page