Step 4: Notifications
After a payment completes, Telebirr POSTs a signed JSON notification to your notify_url. Verify the signature before trusting anything in it.
Skipping signature verification is a security hole. An attacker can POST a fake notification claiming any order was paid. Always call verify() first.
The library way
use Melaku\Telebirr\NotificationHandler;
// notify.php — your notifyUrl endpoint
$rawBody = file_get_contents('php://input');
try {
$notification = NotificationHandler::parse($rawBody);
} catch (\InvalidArgumentException $e) {
NotificationHandler::respondError('Invalid JSON')->send();
exit;
}
// Verify signature using Telebirr's public key (in your Config)
if (!NotificationHandler::verify($notification, $config)) {
NotificationHandler::respondError('Signature verification failed')->send();
exit;
}
if (NotificationHandler::isPaymentSuccessful($notification)) {
$info = NotificationHandler::extractPaymentInfo($notification);
// $info['merchantOrderId'] — match this to your order
// $info['paymentOrderId'] — Telebirr's own order ID
// $info['amount'] — payment amount
// $info['tradeStatus'] — "Completed"
updateOrderStatus($info['merchantOrderId'], 'paid');
fulfillOrder($info['merchantOrderId']);
}
// Always respond 200 — Telebirr retries up to 15 times if it doesn't get 200
NotificationHandler::respondSuccess()->send();Belt and braces (PHP v2.2.0+, JS v3.1.0+): before fulfilling, confirm the status server-to-server with one call — $client->getOrderStatus($info['merchantOrderId']) / await client.getOrderStatus(info.merchantOrderId) — and check paid and the amount against your own order. The signature proves the notification wasn’t tampered with; the query proves the money actually moved. Both libraries’ READMEs document the full idempotent settlement pattern (the browser return and this notification race — use a compare-and-set so they can’t double-fulfill).
Raw API (all languages)
// Raw PHP — without the library
$rawBody = file_get_contents('php://input');
$notification = json_decode($rawBody, true);
if (!$notification || empty($notification['sign'])) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'Missing signature']);
exit;
}
// Rebuild the signed string: extract sign, sort remaining fields by ASCII
$sign = $notification['sign'];
$data = $notification;
unset($data['sign'], $data['sign_type']);
ksort($data);
$rawStr = http_build_query($data); // key=value&...
// Verify against Telebirr's public key with SHA256WithRSA PSS
$publicKey = openssl_pkey_get_public($telebirrPublicKeyPem);
$result = openssl_verify(
$rawStr,
base64_decode($sign),
$publicKey,
OPENSSL_ALGO_SHA256
);
if ($result !== 1) {
http_response_code(403);
echo json_encode(['success' => false, 'message' => 'Invalid signature']);
exit;
}
if (($notification['trade_status'] ?? '') === 'Completed') {
$orderId = $notification['merch_order_id'];
updateOrderStatus($orderId, 'paid');
}
http_response_code(200);
echo json_encode(['success' => true]);Notification payload
{
"notify_url": "https://your-site.com/pay/notify",
"appid": "853694808089634",
"notify_time": "1670575472482",
"merch_code": "245445",
"merch_order_id": "1670575560882",
"payment_order_id": "00801104C911443200001002",
"total_amount": "10.00",
"trans_currency": "ETB",
"trade_status": "Completed",
"trans_end_time": "1670575472000",
"sign": "AOwWQF0QDg0jzzs5...",
"sign_type": "SHA256WithRSA"
}Payment status values
trade_status | Meaning |
|---|---|
Completed | Payment successful ✅ |
Paying | User has initiated payment but process isn’t complete |
Pending | Waiting for payment |
Expired | Payment window closed without payment |
Failure | Payment failed |
Retry behavior
If your endpoint doesn’t respond with HTTP 200, Telebirr retries the notification up to 15 times on this schedule:
15s → 15s → 30s → 3m → 10m → 20m → 30m → 30m → 60m → 3h → 3h → 3h → 3h → 6h → 6h
Make your notification handler idempotent — check merch_order_id before updating order state to avoid double-fulfillment.
Your notify URL must be publicly reachable and whitelisted by Telebirr. Localhost won’t work. Use a tool like ngrok for local testing.