Webhooks
Receive real-time payment notifications without polling.
Webhooks
ePay sends an HTTP POST to your configured callback URL whenever a payment event occurs. Set a global callback URL in your dashboard, or supply a per-transaction callbackUrl when initializing — the per-transaction URL always takes precedence.
Webhook delivery requires HTTPS. HTTP callback URLs are not accepted.
Events
| Event | Triggered when |
|---|---|
payment.success | Payment completed successfully. |
payment.failed | Payment attempt failed. |
payment.cancelled | Transaction cancelled via the API or checkout. |
payment.refunding | A refund has been initiated and is being processed. |
payment.refunded | Refund completed successfully. |
payment.reversed | Payment was reversed. |
Payload
Every event delivers the same payload shape:
{
"event": "payment.success",
"mode": "live",
"reference": "PAB12CD3420260813",
"merchantReference": "order_123",
"amount": "250.00",
"serviceFee": "7.50",
"currency": "ETB",
"status": "completed",
"paymentMethod": "telebirr",
"customer": {
"name": "Abebe Bikila",
"email": "abebe@example.com",
"phone": "+251911234567"
},
"paidAt": "2026-08-13T11:47:00.000Z",
"createdAt": "2026-08-13T11:30:00.000Z"
}Prop
Type
Signature Verification
Every request includes an X-Epay-Signature header. Always verify it before processing the payload.
X-Epay-Signature: sha256={hex_signature}The signature is HMAC-SHA256 over the raw request body using your webhook secret key.
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.
# Replay a signed delivery against your local endpoint while you build.
BODY='{"event":"payment.success","mode":"sandbox","reference":"PAB12CD3420260813","status":"completed"}'
SIG=$(printf '%s' "$BODY" \
| openssl dgst -sha256 -hmac "$EPAY_WEBHOOK_SECRET" -r \
| cut -d' ' -f1)
curl http://localhost:3000/webhooks/epay \
-X POST \
-H "Content-Type: application/json" \
-H "X-Epay-Signature: sha256=$SIG" \
--data-raw "$BODY"
// Express — mount this BEFORE any global express.json().
import express from 'express';
import { expressEpayWebhook } from '@e-pay/node/express';
const app = express();
app.post(
'/webhooks/epay',
expressEpayWebhook({
secret: process.env.EPAY_WEBHOOK_SECRET!,
onEvent: async (event) => {
// Signature already verified — an invalid one never reaches here.
await queue.enqueue(event); // acknowledge fast, process later
},
}),
);import { createHmac, timingSafeEqual } from 'node:crypto';
function verifyWebhook(rawBody: Buffer, signatureHeader: string, secret: string) {
const received = Buffer.from(signatureHeader.replace('sha256=', ''));
const expected = Buffer.from(
createHmac('sha256', secret).update(rawBody).digest('hex'),
);
return (
received.length === expected.length && timingSafeEqual(received, expected)
);
}
// express.raw() keeps the body exactly as it arrived on the wire.
app.post('/webhooks/epay', express.raw({ type: 'application/json' }), (req, res) => {
const sig = req.headers['x-epay-signature'] as string;
if (!verifyWebhook(req.body, sig, process.env.EPAY_WEBHOOK_SECRET!)) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(req.body.toString());
queue.enqueue(event);
res.sendStatus(200);
});
import os
from typing import Optional
from epay import EpayWebhookSignatureError, construct_webhook_event
WEBHOOK_SECRET = os.environ["EPAY_WEBHOOK_SECRET"]
def handle_epay_webhook(raw_body: bytes, signature_header: Optional[str]) -> int:
"""Any framework: pass the raw request bytes and the X-Epay-Signature header."""
try:
# Fails closed — any event it returns had a valid signature.
event = construct_webhook_event(raw_body, signature_header, WEBHOOK_SECRET)
except EpayWebhookSignatureError:
return 401
if event["event"] == "payment.success":
fulfil(event["reference"])
else:
queue.enqueue(event) # acknowledge fast, process later
return 200import hashlib
import hmac
import os
from flask import Flask, abort, request
app = Flask(**name**)
def verify_webhook(raw_body: bytes, signature_header: str, secret: str) -> bool:
received = signature_header.replace("sha256=", "")
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(received, expected)
@app.post("/webhooks/epay")
def epay_webhook():
signature = request.headers.get("X-Epay-Signature", "")
# get_data() is the raw body — never re-serialize the parsed JSON.
if not verify_webhook(request.get_data(), signature, os.environ["EPAY_WEBHOOK_SECRET"]):
abort(401)
event = request.get_json()
queue.enqueue(event)
return "", 200
use Epay\Epay;
use Epay\Exception\EpayWebhookSignatureException;
$epay = new Epay(); // reads EPAY_SECRET_KEY and EPAY_WEBHOOK_SECRET
$raw = file_get_contents('php://input');
$signature = $\_SERVER['HTTP_X_EPAY_SIGNATURE'] ?? null;
try {
// Fails closed — any event it returns had a valid signature.
$event = $epay->webhooks->constructEvent($raw, $signature);
} catch (EpayWebhookSignatureException) {
http_response_code(401);
exit;
}
enqueue($event); // acknowledge fast, process later
http_response_code(200);
function verify_webhook(string $rawBody, ?string $signatureHeader, string $secret): bool
{
if ($signatureHeader === null) {
return false;
}
$received = str_replace('sha256=', '', $signatureHeader);
$expected = hash_hmac('sha256', $rawBody, $secret);
return hash_equals($expected, $received);
}
// php://input is the raw body — never re-encode the decoded array.
$raw = file_get_contents('php://input');
$signature = $\_SERVER['HTTP_X_EPAY_SIGNATURE'] ?? null;
if (!verify_webhook($raw, $signature, getenv('EPAY_WEBHOOK_SECRET'))) {
http_response_code(401);
exit;
}
$event = json_decode($raw, true);
enqueue($event);
http_response_code(200);
verifier := epay.NewWebhookVerifier(os.Getenv("EPAY_WEBHOOK_SECRET"))
// Ready-made handler: 401 on a bad or missing signature, 400 on an
// unreadable body, 500 if your callback returns an error.
mux.Handle("/webhooks/epay", verifier.Handler(func(event \*epay.WebhookEvent) error {
return queue.Enqueue(event) // acknowledge fast, process later
}))
// For more control, verify inside your own handler:
func handleWebhook(w http.ResponseWriter, r \*http.Request) {
event, err := verifier.ConstructEventFromRequest(r, 0) // 0 = 1 MiB cap
if err != nil {
status := http.StatusBadRequest
if errors.Is(err, epay.ErrWebhookSignature) {
status = http.StatusUnauthorized
}
http.Error(w, "rejected", status)
return
}
switch event.Event {
case epay.EventPaymentSuccess:
fulfil(event.Reference)
case epay.EventPaymentFailed, epay.EventPaymentCancelled:
release(event.Reference)
}
w.WriteHeader(http.StatusOK)
}
func verifyWebhook(body []byte, signatureHeader, secret string) bool {
received := strings.TrimPrefix(signatureHeader, "sha256=")
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(received), []byte(expected))
}
func webhookHandler(w http.ResponseWriter, r \*http.Request) {
// Read the body before decoding — never re-marshal a decoded struct.
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
http.Error(w, "unreadable body", http.StatusBadRequest)
return
}
signature := r.Header.Get("X-Epay-Signature")
if !verifyWebhook(body, signature, os.Getenv("EPAY_WEBHOOK_SECRET")) {
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}
var event map[string]any
if err := json.Unmarshal(body, &event); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
enqueue(event)
w.WriteHeader(http.StatusOK)
}
// Any servlet container. On Spring Boot, pick the Spring Boot guide instead.
@WebServlet("/webhooks/epay")
public class EpayWebhookServlet extends HttpServlet {
private final Epay epay = Epay.fromEnvironment(); // reads EPAY_WEBHOOK_SECRET too
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws IOException {
// The raw bytes — never re-serialize parsed JSON before verifying.
byte[] raw = request.getInputStream().readAllBytes();
String signature = request.getHeader(Webhooks.SIGNATURE_HEADER);
WebhookEvent event;
try {
// Fails closed — any event it returns had a valid signature.
event = epay.webhooks().constructEvent(raw, signature);
} catch (EpayWebhookSignatureException e) {
response.setStatus(401);
return;
}
switch (event.type()) {
case PAYMENT_SUCCESS -> fulfil(event.reference());
case PAYMENT_FAILED, PAYMENT_CANCELLED -> release(event.reference());
default -> queue.publish(event); // acknowledge fast, process later
}
response.setStatus(200);
}
}static boolean verifyWebhook(byte[] body, String signatureHeader, String secret)
throws GeneralSecurityException {
if (signatureHeader == null || !signatureHeader.startsWith("sha256=")) {
return false;
}
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
String expected = HexFormat.of().formatHex(mac.doFinal(body));
// Constant-time comparison — never String.equals for a signature.
return MessageDigest.isEqual(
expected.getBytes(StandardCharsets.US_ASCII),
signatureHeader.substring("sha256=".length()).getBytes(StandardCharsets.US_ASCII));
}
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws IOException, GeneralSecurityException {
// The raw bytes — never re-serialize parsed JSON before verifying.
byte[] body = request.getInputStream().readAllBytes();
String signature = request.getHeader("X-Epay-Signature");
if (!verifyWebhook(body, signature, System.getenv("EPAY_WEBHOOK_SECRET"))) {
response.sendError(401, "Invalid signature");
return;
}
queue.publish(body); // parse with your JSON library, then process
response.setStatus(200);
}// main.ts — Nest keeps request.rawBody alongside the parsed body
const app = await NestFactory.create(AppModule, { rawBody: true });
// epay-webhook.controller.ts
import { Controller, HttpCode, Post, UseGuards } from '@nestjs/common';
import {
EpayEvent,
EpayWebhookGuard,
type WebhookEvent,
} from '@e-pay/nestjs';
@Controller('webhooks')
export class EpayWebhookController {
constructor(private readonly queue: QueueService) {}
@Post('epay')
@HttpCode(200)
@UseGuards(EpayWebhookGuard) // 401 on a bad or missing signature
async handle(@EpayEvent() event: WebhookEvent) {
// The signature is already verified; an invalid one never reaches here.
await this.queue.enqueue('epay-event', event);
}
}from django.conf import settings
from django.http import HttpResponse, HttpResponseForbidden
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from epay import EpayWebhookSignatureError
from epay.integrations.django import construct_event_from_request
@csrf_exempt # ePay is a third party and sends no CSRF token
@require_POST
def epay_webhook(request):
try:
event = construct_event_from_request(request, settings.EPAY_WEBHOOK_SECRET)
except EpayWebhookSignatureError as error:
return HttpResponseForbidden(str(error))
enqueue(event) # acknowledge fast, process later
return HttpResponse(status=200)import os
from flask import Flask, abort, request
from epay import EpayWebhookSignatureError
from epay.integrations.flask import construct_event_from_request
app = Flask(__name__)
@app.post("/webhooks/epay")
def epay_webhook():
try:
event = construct_event_from_request(request, os.environ["EPAY_WEBHOOK_SECRET"])
except EpayWebhookSignatureError as error:
abort(401, str(error))
queue.enqueue(event) # acknowledge fast, process later
return "", 200from fastapi import FastAPI, HTTPException, Request
from epay import EpayWebhookSignatureError
from epay.integrations.asgi import construct_event_from_request
app = FastAPI()
@app.post("/webhooks/epay", status_code=200)
async def epay_webhook(request: Request):
# Take the whole Request, not a Pydantic model — FastAPI would hand you
# re-serialized JSON, whose digest no longer matches.
try:
event = await construct_event_from_request(request, settings.epay_webhook_secret)
except EpayWebhookSignatureError as error:
raise HTTPException(status_code=401, detail=str(error))
await queue.enqueue(event)
return {"received": True}// bootstrap/app.php (Laravel 11+): ePay sends no CSRF token
use Epay\Laravel\VerifyEpayWebhook;
->withMiddleware(function (Middleware $middleware) {
$middleware->validateCsrfTokens(except: ['webhooks/epay']);
$middleware->alias(['epay.webhook' => VerifyEpayWebhook::class]);
})
// routes/web.php — the middleware answers 401 before your code runs
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');// application.yml
// epay:
// webhook-secret: ${EPAY_WEBHOOK_SECRET}
@RestController
class EpayWebhookController {
// Verified against the raw bytes before this runs: 401 on a bad
// signature, 400 on a body that is not a JSON object. Do not add
// @RequestBody here — it would consume the body before verification.
@PostMapping("/webhooks/epay")
ResponseEntity<Void> handle(@EpayWebhook WebhookEvent event) {
queue.publish(event); // acknowledge fast, process later
return ResponseEntity.ok().build();
}
}
// With Spring Security, open the route and exempt it from CSRF —
// the signature is what authenticates the request:
http
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.POST, "/webhooks/epay").permitAll()
.anyRequest().authenticated())
.csrf(csrf -> csrf.ignoringRequestMatchers("/webhooks/epay"));Always verify against the raw request body before JSON-parsing. Re-stringifying
parsed JSON may alter whitespace or key order, causing valid signatures to fail.
Use timingSafeEqual / hmac.Equal — never === — to prevent timing attacks.
Retries
If your endpoint does not return a 2xx within 10 seconds, ePay retries with exponential backoff.
| Attempt | Delay |
|---|---|
| 1 | immediate |
| 2 | ~1 minute |
| 3 | ~2 minutes |
| 4 | ~4 minutes |
| 5 | ~8 minutes |
After 5 failed attempts the delivery is marked permanently failed.
Respond with 2xx immediately and process the event asynchronously. Slow
responses that exceed 10 seconds are treated as failures. Make your handler
idempotent — use reference to deduplicate repeated deliveries.
Next: Error Codes — full reference of every status code and error message.