melaku/telebirr

Telebirr Web Checkout PHP library (modern API, C2B Web Checkout).

Maintainers

Package info

github.com/MelakuDemeke/telebirr-php

pkg:composer/melaku/telebirr

Transparency log

Statistics

Installs: 2 702

Dependents: 0

Suggesters: 0

Stars: 15

Open Issues: 0

2.2.0 2026-07-16 12:38 UTC

This package is auto-updated.

Last update: 2026-07-16 12:39:07 UTC


README

Telebirr

Telebirr PHP Library (Web Checkout)

GitHub branch checks state GitHub repo size GitHub issues Packagist Downloads Packagist Stars GitHub GitHub Repo stars GitHub forks GitHub commit activity GitHub last commit

A modern PHP library for integrating Telebirr Web Checkout (C2B) payments. Telebirr is a mobile money service developed by Huawei and owned by Ethio telecom.

This library provides a simple, easy-to-use API for handling Telebirr payments, fully compliant with the Telebirr H5 C2B Web Payment Integration Guide.

πŸš€ Quick Start

Installation

composer require melaku/telebirr

Basic Usage

require 'vendor/autoload.php';

use Melaku\Telebirr\Config;
use Melaku\Telebirr\Telebirr;

// Configure (test environment)
$config = Config::forTest([
    'fabricAppId'   => 'YOUR_FABRIC_APP_ID',
    'appSecret'     => 'YOUR_APP_SECRET',
    'merchantAppId' => 'YOUR_MERCHANT_APP_ID',
    'merchantCode'  => 'YOUR_MERCHANT_CODE',
    'privateKey'    => 'YOUR_PRIVATE_KEY_PEM',
    'notifyUrl'     => 'https://your-domain.com/telebirr/notify',
    'redirectUrl'   => 'https://your-domain.com/telebirr/return',
]);

$client = new Telebirr($config);

// Create checkout URL (one line!). Returns a CheckoutResult.
$result = $client->createCheckoutUrl('Order 123', '100.00');

// IMPORTANT: persist the EXACT merch_order_id the library used β€” Telebirr
// echoes this value back in notifications and on the return URL. Storing a
// different value (e.g. one you thought you passed) can cause lookup misses.
saveOrder($result->getMerchOrderId(), $result->getPrepayId()); // your code

// Redirect customer to Telebirr
header('Location: ' . $result->getCheckoutUrl());
exit;

That's it! The library handles token management, order creation, and checkout URL generation automatically.

Merchant order id charset: a merch_order_id must match ^[A-Za-z0-9]+$ (ASCII letters and digits only β€” no -, _, . or spaces). Invalid ids now throw an InvalidParameterException instead of being silently rewritten. Pass null to have a valid id generated for you, and read it back from the result.

In-App SDK Payment

If your mobile app's Telebirr SDK initiates the payment instead of a browser redirect, use createInAppOrder(). There's no checkout URL for this flow β€” the response's receiveCode must be passed to the mobile SDK to continue the payment.

$tokenInfo   = $client->applyFabricToken();
$fabricToken = $tokenInfo['token'];

$order = $client->createInAppOrder($fabricToken, 'Order 123', '100.00');
$receiveCode = $order['biz_content']['receiveCode'];

// Send the receiveCode to your mobile app for the SDK to complete the payment.
header('Content-Type: application/json');
echo json_encode(['receiveCode' => $receiveCode]);

πŸ“‹ Configuration

Required Credentials

You'll receive these from Telebirr:

  • fabricAppId - Your Fabric App ID (UUID)
  • appSecret - Your App Secret
  • merchantAppId - Your Merchant App ID
  • merchantCode - Your Merchant Code (6-digit)
  • privateKey - Your RSA Private Key
  • notifyUrl - Server-to-server notification URL (required)
  • redirectUrl - User return URL after payment (optional)

Key formats β€” bare base64 is fine

Ethio Telecom issues merchant keys as bare base64 DER (a single long MIIEvgIBADANBgk… line, no -----BEGIN…----- armor). Pass it exactly as issued β€” the library normalizes it to PEM automatically, picking the right header (PKCS#8 vs PKCS#1) for you. Proper PEM works too, including PEM whose newlines were flattened to literal \n by a .env file.

Environment Setup

The library automatically uses the correct URLs based on environment:

// Test/Development
$config = Config::forTest([...]);

// Production
$config = Config::forProduction([...]);

// Zero-config: read everything from environment variables
$config = Config::fromEnvironment();

Config::fromEnvironment() reads (any explicit option overrides its variable; $_ENV, $_SERVER, and getenv() are all checked, so it works under php-fpm/Laravel too):

Variable Maps to
TELEBIRR_ENVIRONMENT (then APP_ENV) environment
TELEBIRR_FABRIC_APP_ID fabricAppId
TELEBIRR_APP_SECRET appSecret
TELEBIRR_MERCHANT_APP_ID merchantAppId
TELEBIRR_MERCHANT_CODE merchantCode
TELEBIRR_PRIVATE_KEY privateKey (PEM or bare base64)
TELEBIRR_NOTIFY_URL notifyUrl
TELEBIRR_REDIRECT_URL redirectUrl
TELEBIRR_PUBLIC_KEY telebirrPublicKey

Default endpoints used by the library:

πŸ’‘ Key Features

  • βœ… Simple API - One-call checkout (createCheckoutUrl) and one-call verification (getOrderStatus)
  • βœ… Automatic Token Management - Fabric tokens are fetched, cached until expiry, and refreshed for you
  • βœ… Key normalization - Bare base64 keys (as Ethio Telecom issues them) or PEM, both just work
  • βœ… TLS that just works - Falls back to a bundled Telebirr CA chain when the test gateway's incomplete chain fails the system store; no verifySsl => false needed
  • βœ… Structured errors + opt-in retry - Branch on $e->getTelebirrCode(); retry transient sandbox errors with backoff
  • βœ… Signature Verification - Built-in helpers for return URLs and notifications
  • βœ… Helper Classes - ReturnUrlHandler, NotificationHandler, PaymentStatus
  • βœ… Environment Support - Automatic test/production URL handling
  • βœ… Full Compliance - Follows Telebirr H5 C2B Web Payment Integration spec

πŸ“– Common Use Cases

Verify a payment (getOrderStatus)

The one-call, server-to-server way to confirm what actually happened to an order β€” the verification counterpart to createCheckoutUrl. Token handling and response mapping are done for you:

$status = $client->getOrderStatus('YOUR_MERCH_ORDER_ID');

$status->paid;           // bool β€” true ONLY on an explicit success status (fails closed)
$status->tradeStatus;    // e.g. 'PAY_SUCCESS'
$status->amount;         // e.g. '100.00' β€” VERIFY this against your own order amount
$status->currency;       // 'ETB'
$status->paymentOrderId; // Telebirr's transaction reference (or null)
$status->raw;            // the full queryOrder response if you need more

Handle Payment Return

use Melaku\Telebirr\ReturnUrlHandler;

try {
    // Fails closed: throws if the signature is missing or invalid.
    $paymentData = ReturnUrlHandler::handle($_GET, $config);

    if ($paymentData['isSuccess']) {
        // The return URL comes through the user's browser and is spoofable even
        // when signed. For anything that fulfils an order, confirm the real
        // status server-to-server before acting on it:
        $status = $client->getOrderStatus($paymentData['merchantOrderId']);
        if ($status->paid && $status->amount === $expectedAmount) {
            // Update your database / fulfill the order β€” idempotently (see below).
        }
    }
} catch (\RuntimeException $e) {
    // Missing/invalid signature
    http_response_code(400);
    echo "Invalid payment data";
}

Return-URL parameters (the raw contract)

Telebirr redirects the user's browser to your redirectUrl with these query parameters appended (snake_case):

Parameter Meaning
merch_order_id Your merchant order id, echoed back verbatim
payment_order_id Telebirr's transaction reference
trade_status e.g. PAY_SUCCESS, PAY_FAILED, PAY_CANCEL
total_amount Order amount
trans_currency Currency (ETB)
trans_end_time Transaction end time
sign, sign_type RSA-PSS signature over the other params

ReturnUrlHandler::handle() verifies the signature and maps these for you β€” the table is here for when you're debugging the raw redirect.

The idempotent settlement pattern (recommended)

The browser return and the server notification race β€” either can arrive first, both can arrive, and neither should be trusted on its own. The production-correct shape:

  1. On checkout, store a row keyed by merchOrderId with status='pending' and the expected amount.
  2. On both the return handler and the notify handler, call $client->getOrderStatus($merchOrderId) β€” never trust the callback params.
  3. Verify $status->paid === true and $status->amount matches your stored amount.
  4. Grant idempotently with a compare-and-set, so the racing paths can't double-fulfill:
function settle(Telebirr $client, PDO $db, string $merchOrderId): void
{
    $status = $client->getOrderStatus($merchOrderId);
    if (!$status->paid) {
        return;
    }

    // Atomic claim: only one caller flips pending β†’ success.
    $stmt = $db->prepare(
        "UPDATE orders SET status = 'success'
         WHERE merch_order_id = :id AND status = 'pending' AND amount = :amount"
    );
    $stmt->execute(['id' => $merchOrderId, 'amount' => $status->amount]);

    if ($stmt->rowCount() === 1) {
        fulfillOrder($merchOrderId); // runs exactly once
    }
}

Notification acknowledgement contract

  • Telebirr POSTs the notification as a JSON body to your notifyUrl.
  • Acknowledge success with HTTP 200 and a JSON body β€” this is what NotificationHandler::respondSuccess() emits: {"success": true}.
  • Any non-2xx status tells Telebirr the delivery failed; it will retry the notification later. Respond 200 once you have durably recorded the event, and reserve error responses for "I could not record this, please retry".
  • Your notifyUrl must be publicly reachable β€” localhost or a private address will never receive anything (the library warns about this at construction time). In development use a tunnel (ngrok, cloudflared).

Handle Payment Notifications

use Melaku\Telebirr\NotificationHandler;

$rawData = file_get_contents('php://input');
$notification = NotificationHandler::parse($rawData);

// Verify signature
if (!NotificationHandler::verify($notification, $config)) {
    // respond* now RETURN a NotificationResponse (no header()/echo). In a
    // framework, convert it to your Response object. In bare PHP, call send().
    NotificationHandler::respondError('Invalid signature')->send();
    exit;
}

// Process payment
if (NotificationHandler::isPaymentSuccessful($notification)) {
    $paymentInfo = NotificationHandler::extractPaymentInfo($notification);
    // Update database, fulfill order, etc.

    NotificationHandler::respondSuccess('Payment processed')->send();
}

Framework usage: instead of ->send(), build a native response, e.g. in Laravel: return response(json: $resp->getBody(), status: $resp->getStatusCode());

Query Order Status (low level)

Prefer getOrderStatus() above; the raw call remains available when you need the untouched response:

$tokenInfo = $client->applyFabricToken();
$orderStatus = $client->queryOrder($tokenInfo['token'], null, 'YOUR_ORDER_ID');

$tradeStatus = $orderStatus['biz_content']['trade_status'] ?? '';
if (strtoupper($tradeStatus) === 'PAY_SUCCESS') {
    // Payment successful
}

Check gateway health

The sandbox can be flaky; probe it before a user-facing checkout if you want to degrade gracefully:

$health = $client->ping(); // never throws
if (!$health['ok']) {
    // show "payment temporarily unavailable" instead of a broken checkout
}

Process Refund

$tokenInfo = $client->applyFabricToken();
$refundResult = $client->refundOrder(
    $tokenInfo['token'],
    '50.00',              // Refund amount
    'PAYMENT_ORDER_ID',   // or null
    'MERCHANT_ORDER_ID',  // or null
    'Refund reason'       // Optional
);

πŸ”§ Requirements

  • PHP >= 7.4
  • ext-curl extension
  • ext-openssl extension (used by the legacy Notify class for payload decryption only)
  • phpseclib/phpseclib (^3.0) β€” Signer and SignatureVerifier use phpseclib only (pure-PHP). No OpenSSL CLI or ext-openssl required for signing/verification. Works on all platforms including Windows. Algorithm: RSA-PSS, SHA256, MGF1-SHA256, salt length 32.
  • psr/log (^1.1 || ^2.0 || ^3.0) β€” the library type-hints the standard Psr\Log\LoggerInterface, so any PSR-3 logger (Monolog, Laravel's logger, …) drops straight in.

βš™οΈ Advanced Configuration

TLS & timeouts

The default HTTP client verifies the gateway's TLS certificate and applies timeouts (a payment gateway must not be called over an unverified or unbounded connection).

The Telebirr test gateway serves an incomplete certificate chain (leaf only, missing intermediate), which used to fail verification with cURL error 60 and push people toward 'verifySsl' => false. The library now ships the gateway's CA chain (src/certs/telebirr-ca.pem): when system-store verification fails with error 60 and no custom bundle was supplied, the request is retried once against the bundled chain β€” so verification works out of the box. The bundled chain can only validate hosts issued under it (the Telebirr gateways); it never loosens verification for anything else. If verification still fails, the error explains the options.

$config = Config::forProduction([
    // ... credentials ...
    'verifySsl'      => true,   // default true β€” leave on; the library warns (test) or
                                 // logs an error (production) if you turn it off
    'caBundlePath'   => null,   // optional path to a custom CA bundle (PEM);
                                 // supplying one disables the bundled-CA fallback
    'timeout'        => 30,     // total request timeout (seconds)
    'connectTimeout' => 10,     // connection timeout (seconds)
]);

Token caching

createCheckoutUrl() and getOrderStatus() cache the fabric token until its expirationDate (minus a 60s safety margin) within the client instance and reuse it, saving a gateway round-trip whenever one request performs several calls (e.g. a settle path). A rejected token (HTTP 401) drops the cache automatically. Note PHP's request lifecycle: the cache does not persist across requests. Opt out for strictly stateless behavior:

$client = new Telebirr($config, null, null, ['cacheFabricToken' => false]);

applyFabricToken() always performs a real network call (and refreshes the cache), so existing manual flows are unaffected.

Retrying transient gateway errors

The test gateway regularly throws transient infra errors (see the sandbox note below). Retry is opt-in with exponential backoff:

$client = new Telebirr($config, $logger, null, [
    'retry' => ['retries' => 2, 'delayMs' => 500, 'maxDelayMs' => 5000],
]);

Only failures where ApiException::isTransient() is true are retried: known Telebirr infra codes (49401024991 "southbound service unavailable"), HTTP 502/503/504, and cURL timeouts/connection drops. Parameter or auth errors fail immediately. The code list is ApiException::TRANSIENT_TELEBIRR_ERROR_CODES.

PSR-3 logging

use Monolog\Logger;

$log = new Logger('telebirr');
$client = new Telebirr($config, $log); // request/response logging (secrets & PII redacted)

Injecting a custom HTTP client (testing)

The third constructor argument accepts any Melaku\Telebirr\Http\HttpClientInterface, so you can unit-test without hitting the network:

use Melaku\Telebirr\Http\HttpClientInterface;
use Melaku\Telebirr\Http\HttpResponse;

$fake = new class implements HttpClientInterface {
    public function post(string $url, array $headers, string $body): HttpResponse {
        return new HttpResponse(200, '{"token":"Bearer TEST"}');
    }
};

$client = new Telebirr($config, null, $fake);

Catching errors

Every exception the library throws implements Melaku\Telebirr\Exceptions\TelebirrExceptionInterface, so you can catch them all in one place. API failures throw ApiException, which now carries Telebirr's parsed error envelope β€” no more json_decode($e->getResponseBody()):

use Melaku\Telebirr\Exceptions\ApiException;

try {
    $client->createCheckoutUrl('Order 123', '100.00');
} catch (ApiException $e) {
    $e->getHttpStatus();       // e.g. 400
    $e->getTelebirrCode();     // e.g. '49401024991' β€” parsed from the body
    $e->getTelebirrMessage();  // Telebirr's errorMsg
    $e->getTelebirrSolution(); // Telebirr's errorSolution remediation text
    $e->isTransient();         // true for retryable gateway-side failures
    $e->getResponseBody();     // raw body, if you need it
}

Amounts & rounding

amount accepts string|int|float and is formatted to exactly 2 decimals β€” Telebirr's wire format for ETB. If you store amounts in minor units (cents), divide before passing ($cents / 100). Prefer passing a string ('100.50') when the value came from user input or a DB decimal column, sidestepping binary floating-point surprises.

⚠️ Sandbox instability

The test gateway is frequently unstable and returns transient infra errors that look exactly like integration bugs β€” most commonly:

errorCode 49401024991: "southbound business service is unavailable"

If your request worked before and suddenly throws a 4940… code with an errorSolution suggesting a retry, it's the gateway, not your code. Wait and retry (or enable the retry option above). Don't spend an hour debugging a correct integration.

πŸ“š Documentation

For detailed documentation, API reference, and advanced usage examples, visit our documentation site:

πŸ”— Full Documentation (Coming Soon)

The documentation includes:

  • Complete API reference
  • Step-by-step integration guides
  • Advanced configuration options
  • Signature verification details
  • Webhook/notification handling
  • Error handling and troubleshooting
  • Security best practices

πŸ› οΈ Helper Classes

The library provides several helper classes to simplify common tasks:

  • ReturnUrlHandler - Parse and verify return URL parameters
  • NotificationHandler - Parse and verify payment notifications
  • PaymentStatus - Check payment status values
  • SignatureVerifier - Verify signatures from Telebirr

πŸ”’ Security Notes

  • Always verify signatures before processing payments
  • Use HTTPS for all payment endpoints
  • Store credentials in environment variables, not in code
  • Implement idempotency checks for notifications
  • Never trust return URL parameters alone - verify with server-to-server notifications

🀝 Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

πŸ“„ License

This project is licensed under the MIT License.

πŸ”— Links

Need help? Check out the full documentation or open an issue on GitHub.