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/nodepip install epaycomposer require epay-et/php-sdkgo get github.com/epay-et/go-sdk# 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);
});
# Flask
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 "", 200
# Django and FastAPI have the same helper under
# epay.integrations.django and epay.integrations.asgi.import 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);
// On Laravel, use the Epay\Laravel\VerifyEpayWebhook middleware instead:
// Route::post('/webhooks/epay', fn (Request $r) => VerifyEpayWebhook::event($r))
// ->middleware('epay.webhook');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)
}
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.