Payments

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

EventTriggered when
payment.successPayment completed successfully.
payment.failedPayment attempt failed.
payment.cancelledTransaction cancelled via the API or checkout.
payment.refundingA refund has been initiated and is being processed.
payment.refundedRefund completed successfully.
payment.reversedPayment 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.

raw HTTP
npm install @e-pay/node
pip install epay
composer require epay-et/php-sdk
go 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.

AttemptDelay
1immediate
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.

On this page