Bill payment API integration with PHP and Laravel

Integrate RizPay from PHP or Laravel with a sandbox client, server-held credentials, current product selection and a purchase recovery workflow.

Already registered? Open API settings and follow the business-access steps. Sandbox purchases do not deliver real services.

Reviewed

This client needs PHP 8.2 or newer and the cURL extension. Load it from your server application. In Laravel, place the classes in your application's service layer with matching namespaces and separate class files for PSR-4 autoloading.

Start with an approved order

Read the secret through your framework configuration and pass it to the constructor. For Laravel deployments that cache configuration, put env access in a configuration file and use config(...) when constructing the client. Never expose it through a frontend environment variable.

Create sandbox access in API settings after completing the account's business-access steps. For this airtime walkthrough, the key needs view_products, purchase_airtime and read_transactions. Keep the secret in RIZPAY_API_KEY on the server. The client below deliberately accepts only sandbox keys and uses a fixed sandbox URL.

The server client

php
<?php
// PHP 8.2+ with cURL. Keep this class on your server.
declare(strict_types=1);

final class RizPayRequestError extends RuntimeException
{
    public function __construct(public readonly string $apiCode, public readonly int $httpStatus)
    {
        parent::__construct('Request not confirmed. Reconcile the stored order before retrying.');
    }
}

final class RizPaySandbox
{
    private const BASE_URL = 'https://my.rizpay.app/api/partners/sandbox/v1';
    private string $key;

    public function __construct(?string $key = null)
    {
        $this->key = $key ?? (getenv('RIZPAY_API_KEY') ?: '');
        if (!str_starts_with($this->key, 'sk_test_')) {
            throw new InvalidArgumentException('A sandbox key is required.');
        }
    }

    public function listAirtime(string $network = 'MTN', int $page = 1): array
    {
        if (!in_array($network, ['MTN', 'AIRTEL', 'GLO', '9MOBILE'], true) || $page < 1) {
            throw new InvalidArgumentException('Check the network and page.');
        }
        return $this->request('/products/airtimes?' . http_build_query(['network' => $network, 'page' => $page]));
    }

    public function purchaseAirtime(string $productId, string $network, string $phoneNumber, string $amount, string $externalReference): array
    {
        if (!preg_match('/\A\d{10}[A-Za-z0-9]{6}\z/', $externalReference) ||
            !preg_match('/\Aprd_\d+\z/', $productId) ||
            !in_array($network, ['MTN', 'AIRTEL', 'GLO', '9MOBILE'], true) ||
            !preg_match('/\A0[789]\d{9}\z/', $phoneNumber) ||
            !preg_match('/\A\d+\.\d{2}\z/', $amount)) {
            throw new InvalidArgumentException('Check the stored order fields.');
        }
        return $this->request('/purchases', [
            'product_id' => $productId, 'network' => $network,
            'phone_number' => $phoneNumber, 'amount' => $amount,
            'external_reference' => $externalReference,
        ]);
    }

    public function findOrder(string $reference): array
    {
        if (!preg_match('/\A\d{10}[A-Za-z0-9]{6}\z/', $reference)) {
            throw new InvalidArgumentException('Invalid reference.');
        }
        return $this->request('/account/transactions/' . $reference);
    }

    public function purchaseStatus(string $id): array
    {
        if (!preg_match('/\Atxn_[A-Za-z0-9-]+\z/', $id)) {
            throw new InvalidArgumentException('Invalid transaction.');
        }
        return $this->request('/purchases/' . $id);
    }

    private function request(string $path, ?array $body = null): array
    {
        $curl = curl_init(self::BASE_URL . $path);
        curl_setopt_array($curl, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_FOLLOWLOCATION => false,
            CURLOPT_CONNECTTIMEOUT => 5,
            CURLOPT_TIMEOUT => 20,
            CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $this->key, 'Content-Type: application/json'],
        ]);
        if ($body !== null) {
            curl_setopt($curl, CURLOPT_POST, true);
            curl_setopt($curl, CURLOPT_POSTFIELDS, json_encode($body, JSON_THROW_ON_ERROR));
        }
        $raw = curl_exec($curl);
        $status = (int) curl_getinfo($curl, CURLINFO_HTTP_CODE);
        curl_close($curl);
        if ($raw === false) {
            throw new RizPayRequestError('UNCONFIRMED_RESPONSE', 0);
        }
        try {
            $json = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
        } catch (JsonException) {
            throw new RizPayRequestError('UNRECOGNIZED_RESPONSE', $status);
        }
        if (!is_array($json)) {
            throw new RizPayRequestError('UNRECOGNIZED_RESPONSE', $status);
        }
        if ($status < 200 || $status >= 300) {
            $code = $json['error']['code'] ?? 'API_ERROR';
            if (!is_string($code) || !preg_match('/\A[A-Z_]{3,60}\z/', $code)) {
                $code = 'API_ERROR';
            }
            throw new RizPayRequestError($code, $status);
        }
        return $json;
    }
}

Use the catalogue before purchasing

Call the catalogue method and read the product array in data. Use an ID from your own result, confirm its network and read its price limits. Keep product IDs as strings and use decimal strings for money. The client checks the input format; your application must still enforce the selected product's minimum and maximum amounts and its own customer authorization rules.

Use phone number 08011111111 for a configured successful sandbox airtime scenario. For pending scenarios, use 08022222222 or 08033333333. These are simulator inputs, not recipients for real top-ups. An arbitrary phone number does not necessarily produce a successful sandbox result.

Persist before sending

Generate the external reference once when you save the order. Follow RizPay's documented 16-character format: ten timestamp digits followed by six alphanumeric characters. Add a unique constraint for that reference in your own database. Pass the same stored value to the purchase method.

These methods do not automatically retry a purchase. If the response is lost, use the reference lookup method and inspect the existing transaction. A DUPLICATE_REFERENCE error is a recovery signal, not proof that the customer received airtime. Protect a local order from concurrent submissions with your application's transaction or job-locking mechanism.

Read the result correctly

An accepted purchase returns its identifier at data.id and its status at data.attributes.status. Persist the identifier with the order. Use the status method to retrieve it again, or reconcile a verified webhook. Pending must remain pending in your customer interface. Do not mark an order paid-and-delivered merely because the HTTP response was successful.

If a result is unrecognized, keep the order unresolved and investigate it. Never show exception details, keys or a raw provider response to the customer. A safe message explains that the result is being checked and provides a route to view the existing order or contact support.

Moving this into a live application

Add customer authorization, durable persistence, payment collection, webhook verification and ledger reconciliation in your own application. The client is the HTTP boundary, not a complete checkout or wallet. Your customer's payment to you and your partner purchase from RizPay are distinct records.

Review the production checklist before introducing a production client and live credentials. Keep test and live clients, keys and order records separate. The airtime reference describes the request; other product categories require their own fields and, for electricity and cable TV, verification.