Skip to content

Airtel Money · Laravel

Airtel Money Collections API in Laravel: USSD Push to Callback

Accept Airtel Money in Laravel 11+: token caching, USSD push payments, callbacks confirmed by the status API, a queued job for TIP and TA, and tests.

Published

Skip the boilerplate → Generate every Airtel Money request in this guide with the Airtel Money Request Builder.

This guide adds Airtel Money payments to a Laravel 11, 12 or 13 app with Airtel’s Open API. The customer gets a USSD prompt on their phone and approves the payment with their PIN. By the end you’ll have:

  • an AirtelClient service with token caching,
  • an airtel_payments table that records every attempt,
  • an endpoint that sends the payment request,
  • a callback route that confirms every callback with the status API in a queued job,
  • handling for TIP (in progress) and TA (ambiguous) that never fails a payment early,
  • a scheduled check for callbacks that never arrive,
  • a feature test that runs the whole flow without calling Airtel.

Everything uses Laravel’s built-in HTTP client. Every code block here was run in a fresh Laravel 13 app, and the test at the end passes.

Airtel’s public documentation is thinner than MTN’s or Safaricom’s, and some details vary by country. Where this guide relies on behaviour reported by developers rather than documented by Airtel, it says so, and the code is written to stay safe either way.

Before you start

  1. Create an application in the Airtel developer portal (developers.airtel.africa) for your country, with the Collection product.
  2. Copy the client ID and client secret from Key Management. UAT (sandbox) and production have separate credentials, and production needs Airtel to approve your business.
  3. Register your callback URL on the application, for example https://your-domain/api/webhooks/airtel. It must be public HTTPS; for local development, use a tunnel such as cloudflared or ngrok.
  4. Make sure a queue worker can run (php artisan queue:work). The database queue driver is fine to start with.

Configuration

Add your credentials to .env:

AIRTEL_ENV=uat
AIRTEL_CLIENT_ID=your-client-id
AIRTEL_CLIENT_SECRET=your-client-secret
AIRTEL_COUNTRY=UG
AIRTEL_CURRENCY=UGX
AIRTEL_COUNTRY_CODE=256

Map them in config/services.php, and always read them through config(). After php artisan config:cache, env() returns null outside config files.

<?php

// config/services.php
return [
    // ...your existing services

    'airtel' => [
        'base_url' => env('AIRTEL_ENV') === 'production' ? 'https://openapi.airtel.africa' : 'https://openapiuat.airtel.africa',
        'client_id' => env('AIRTEL_CLIENT_ID'),
        'client_secret' => env('AIRTEL_CLIENT_SECRET'),
        'country' => env('AIRTEL_COUNTRY', 'UG'),
        'currency' => env('AIRTEL_CURRENCY', 'UGX'),
        'country_code' => env('AIRTEL_COUNTRY_CODE', '256'),
    ],
];

Create the payments table

With Airtel, you choose the transaction ID and send it with the payment request. That ID is how callbacks and status checks find the payment, so it gets its own unique column.

php artisan make:model AirtelPayment -m
<?php

// database/migrations/xxxx_xx_xx_create_airtel_payments_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('airtel_payments', function (Blueprint $table) {
            $table->id();
            $table->string('transaction_id', 32)->unique();   // our ID, sent to Airtel as transaction.id
            $table->string('order_id')->index();
            $table->unsignedBigInteger('amount');              // UGX, KES, TZS... have no cents in practice
            $table->string('currency', 3);
            $table->string('msisdn', 15);                      // without country code, e.g. 752123456
            $table->string('status')->default('pending');      // pending | paid | failed
            $table->string('airtel_money_id')->nullable();     // Airtel's reference once paid
            $table->string('failure_reason')->nullable();
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('airtel_payments');
    }
};
<?php

// app/Models/AirtelPayment.php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class AirtelPayment extends Model
{
    protected $fillable = ['transaction_id', 'order_id', 'amount', 'currency', 'msisdn', 'status', 'airtel_money_id', 'failure_reason'];

    protected $casts = ['amount' => 'integer'];
}

Run php artisan migrate.

The Airtel service class

The client handles the three calls you need: get a token, request a payment, and check its status. Every call carries the X-Country and X-Currency headers for your market.

<?php

// app/Services/AirtelClient.php
namespace App\Services;

use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use InvalidArgumentException;
use RuntimeException;

class AirtelClient
{
    private function config(string $key): string
    {
        return (string) config("services.airtel.$key");
    }

    private function http(): PendingRequest
    {
        return Http::baseUrl($this->config('base_url'))->acceptJson()->timeout(30);
    }

    public function token(): string
    {
        if ($token = Cache::get('airtel.token')) {
            return $token;
        }

        $response = $this->http()->post('/auth/oauth2/token', [
            'client_id' => $this->config('client_id'),
            'client_secret' => $this->config('client_secret'),
            'grant_type' => 'client_credentials',
        ])->throw();

        $token = $response->json('access_token');
        $ttl = max(60, (int) $response->json('expires_in', 180) - 60); // refresh a minute early
        Cache::put('airtel.token', $token, $ttl);

        return $token;
    }

    private function api(): PendingRequest
    {
        return $this->http()->withToken($this->token())->withHeaders([
            'X-Country' => $this->config('country'),
            'X-Currency' => $this->config('currency'),
        ]);
    }

    /** Airtel wants the number without the country code or leading 0: "0752 123 456" → "752123456". */
    public static function msisdn(string $raw, string $countryCode = '256'): string
    {
        $digits = preg_replace('/\D/', '', $raw);
        if (str_starts_with($digits, '00')) {
            $digits = substr($digits, 2);
        }
        if (str_starts_with($digits, $countryCode)) {
            $digits = substr($digits, strlen($countryCode));
        }
        if (str_starts_with($digits, '0')) {
            $digits = substr($digits, 1);
        }
        if (strlen($digits) < 8 || strlen($digits) > 10) {
            throw new InvalidArgumentException("Not a valid mobile number: $raw");
        }

        return $digits;
    }

    /** Sends the USSD prompt. A success only means Airtel received the request. */
    public function requestPayment(string $transactionId, int $amount, string $msisdn, string $reference): void
    {
        $response = $this->api()->post('/merchant/v1/payments/', [
            'reference' => mb_substr($reference, 0, 64),
            'subscriber' => ['country' => $this->config('country'), 'currency' => $this->config('currency'), 'msisdn' => $msisdn],
            'transaction' => [
                'amount' => $amount,
                'country' => $this->config('country'),
                'currency' => $this->config('currency'),
                'id' => $transactionId,
            ],
        ]);

        if ($response->failed() || $response->json('status.success') === false) {
            $code = $response->json('status.response_code') ?? $response->json('status.code') ?? $response->status();
            throw new RuntimeException("Airtel rejected the payment: $code " . $response->json('status.message', ''));
        }
    }

    /** The source of truth: ['status' => 'TS' | 'TF' | 'TIP' | 'TA' | '', 'airtel_money_id' => ..., 'message' => ...]. */
    public function status(string $transactionId): array
    {
        $response = $this->api()->get('/standard/v1/payments/' . rawurlencode($transactionId));
        $txn = $response->json('data.transaction') ?? [];

        return [
            'status' => strtoupper((string) ($txn['status'] ?? '')),
            'airtel_money_id' => $txn['airtel_money_id'] ?? null,
            'message' => $txn['message'] ?? $response->json('status.message'),
            'code' => $response->json('status.response_code') ?? $response->json('status.code'),
        ];
    }
}

Change AIRTEL_COUNTRY_CODE to your market’s calling code, for example 254 for Kenya. Not sure of the format somewhere? Check the number in the African Phone Number Formatter.

Start a payment

The controller creates your transaction ID and saves the row before calling Airtel, so a crash halfway can never lose a payment.

If the request times out, Airtel may still have received it. Because you already know the transaction ID, the code keeps that payment pending and lets the status check settle it, instead of telling the customer to pay again.

<?php

// app/Http/Controllers/AirtelPaymentController.php
namespace App\Http\Controllers;

use App\Models\AirtelPayment;
use App\Services\AirtelClient;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use InvalidArgumentException;
use RuntimeException;

class AirtelPaymentController extends Controller
{
    public function store(Request $request, AirtelClient $airtel)
    {
        $data = $request->validate([
            'order_id' => ['required', 'string', 'max:64'],
            'amount' => ['required', 'integer', 'min:1'], // take it from your order record in a real app
            'phone' => ['required', 'string', 'max:20'],
        ]);

        try {
            $msisdn = AirtelClient::msisdn($data['phone'], (string) config('services.airtel.country_code'));
        } catch (InvalidArgumentException $e) {
            return response()->json(['errors' => ['phone' => [$e->getMessage()]]], 422);
        }

        $payment = AirtelPayment::create([
            'transaction_id' => Str::lower(Str::random(20)), // unique per attempt, never reused
            'order_id' => $data['order_id'],
            'amount' => $data['amount'],
            'currency' => config('services.airtel.currency'),
            'msisdn' => $msisdn,
        ]);

        try {
            $airtel->requestPayment($payment->transaction_id, $payment->amount, $msisdn, "Order {$payment->order_id}");
        } catch (ConnectionException $e) {
            report($e); // it may have gone through: leave it pending, the status check settles it
        } catch (RuntimeException $e) {
            $payment->update(['status' => 'failed', 'failure_reason' => mb_substr($e->getMessage(), 0, 190)]);
            report($e);

            return response()->json(['message' => 'Airtel Money could not send the payment prompt. Please try again.'], 502);
        }

        return response()->json([
            'transaction_id' => $payment->transaction_id,
            'message' => 'Check your phone and enter your Airtel Money PIN.',
        ], 202);
    }

    /** Your frontend polls this, not Airtel. */
    public function show(string $transactionId)
    {
        $payment = AirtelPayment::where('transaction_id', $transactionId)->firstOrFail();

        return response()->json(['status' => $payment->status]);
    }
}

Handle the callback

Airtel can’t send a CSRF token, so the callback lives in routes/api.php, which has no CSRF check and adds an /api prefix. If your app doesn’t have that file yet, run php artisan install:api, then add these routes, keeping one copy of each use line.

Developers report callbacks shaped like {"transaction": {"id": "...", "status_code": "TS"}}, but the shape isn’t consistently documented. This route only reads transaction.id and hands it to a job that asks Airtel, so a different shape, or a fake callback, can’t mark anything paid.

<?php

// routes/api.php
use App\Http\Controllers\AirtelPaymentController;
use App\Jobs\ConfirmAirtelPayment;
use App\Models\AirtelPayment;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

Route::post('/payments/airtel', [AirtelPaymentController::class, 'store']);
Route::get('/payments/airtel/{transactionId}', [AirtelPaymentController::class, 'show']);

Route::post('/webhooks/airtel', function (Request $request) {
    $id = $request->json('transaction.id');
    if (is_string($id) && AirtelPayment::where('transaction_id', $id)->exists()) {
        ConfirmAirtelPayment::dispatch($id); // never trust the body; the job asks Airtel
    }

    return response()->json(['ok' => true]); // always acknowledge
});

The job asks Airtel for the real status, then updates the row inside a locked transaction, so a duplicate callback or the scheduled check running at the same moment can’t settle the payment twice. TIP and TA put the job back on the queue to ask again later.

<?php

// app/Jobs/ConfirmAirtelPayment.php
namespace App\Jobs;

use App\Models\AirtelPayment;
use App\Services\AirtelClient;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\DB;

class ConfirmAirtelPayment implements ShouldQueue
{
    use Queueable;

    public int $tries = 6;

    public function __construct(public string $transactionId)
    {
    }

    public function handle(AirtelClient $airtel): void
    {
        $result = $airtel->status($this->transactionId); // network call outside the lock

        if (! in_array($result['status'], ['TS', 'TF'], true)) {
            $this->release(30 * $this->attempts()); // TIP, TA or no answer: never fail it early

            return;
        }

        DB::transaction(function () use ($result) {
            $payment = AirtelPayment::where('transaction_id', $this->transactionId)->lockForUpdate()->first();
            if (! $payment || $payment->status !== 'pending') {
                return; // unknown, or already settled by another callback or check
            }

            if ($result['status'] === 'TS') {
                $payment->update(['status' => 'paid', 'airtel_money_id' => $result['airtel_money_id']]);
                // Fulfil the order here, e.g. event(new OrderPaid($payment->order_id));
            } else {
                $payment->update(['status' => 'failed', 'failure_reason' => mb_substr((string) ($result['code'] ?? $result['message'] ?? 'TF'), 0, 190)]);
            }
        });
    }
}

What the code does with each status:

Status Meaning What the code does
TS Successful Marks the payment paid and stores Airtel’s ID
TF Failed Marks it failed with Airtel’s code or message
TIP In progress Asks again later
TA Ambiguous Asks again later. Never fails it early

Response codes like DP00800001006 are explained in the Airtel Money error code reference.

Catch callbacks that never arrive

Callbacks can be late or lost, and TA payments need another look. A scheduled check in routes/console.php re-checks every payment that’s been pending for more than two minutes:

<?php

// routes/console.php
use App\Jobs\ConfirmAirtelPayment;
use App\Models\AirtelPayment;
use Illuminate\Support\Facades\Schedule;

Schedule::call(function () {
    AirtelPayment::where('status', 'pending')
        ->where('created_at', '<', now()->subMinutes(2))
        ->where('created_at', '>', now()->subDay())
        ->pluck('transaction_id')
        ->each(fn (string $id) => ConfirmAirtelPayment::dispatch($id));
})->everyFiveMinutes();

This needs Laravel’s scheduler running: a cron entry that calls php artisan schedule:run every minute.

Test it without calling Airtel

Http::fake() replaces Airtel, so this test runs offline and in CI. It checks the request format, that a callback claiming success is ignored while Airtel says TIP, that a real TS marks the payment paid, that a settled payment is never changed again, and that a timeout leaves the payment pending instead of failing it.

<?php

// tests/Feature/AirtelPaymentTest.php
namespace Tests\Feature;

use App\Models\AirtelPayment;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;

class AirtelPaymentTest extends TestCase
{
    use RefreshDatabase;

    private string $airtelStatus = 'TIP';

    protected function setUp(): void
    {
        parent::setUp();
        config([
            'queue.default' => 'sync',
            'services.airtel.base_url' => 'https://openapiuat.airtel.africa',
            'services.airtel.client_id' => 'id',
            'services.airtel.client_secret' => 'secret',
            'services.airtel.country' => 'UG',
            'services.airtel.currency' => 'UGX',
            'services.airtel.country_code' => '256',
        ]);

        Http::fake([
            '*/auth/oauth2/token' => Http::response(['access_token' => 'test-token', 'expires_in' => 3600]),
            '*/merchant/v1/payments/' => Http::response(['status' => ['success' => true, 'code' => '200', 'result_code' => 'ESB000010']]),
            '*/standard/v1/payments/*' => fn () => Http::response([
                'data' => ['transaction' => ['status' => $this->airtelStatus, 'airtel_money_id' => 'MP261029.0741.A00001']],
                'status' => ['success' => true],
            ]),
        ]);
    }

    public function test_payment_is_confirmed_with_the_status_api_not_the_callback(): void
    {
        $id = $this->postJson('/api/payments/airtel', ['order_id' => '1001', 'amount' => 25000, 'phone' => '0752 123 456'])
            ->assertStatus(202)->json('transaction_id');

        Http::assertSent(fn (Request $request) => str_contains($request->url(), '/merchant/v1/payments/')
            && $request->header('X-Country') === ['UG']
            && $request['subscriber']['msisdn'] === '752123456'
            && $request['transaction']['id'] === $id
            && $request['transaction']['amount'] === 25000);

        // The body claims success, but Airtel says still in progress.
        $this->postJson('/api/webhooks/airtel', ['transaction' => ['id' => $id, 'status_code' => 'TS']])->assertOk();
        $this->assertSame('pending', AirtelPayment::first()->status);

        $this->airtelStatus = 'TS';
        $this->postJson('/api/webhooks/airtel', ['transaction' => ['id' => $id, 'status_code' => 'TS']])->assertOk();
        $this->assertDatabaseHas('airtel_payments', ['transaction_id' => $id, 'status' => 'paid', 'airtel_money_id' => 'MP261029.0741.A00001']);
        $this->getJson("/api/payments/airtel/$id")->assertJson(['status' => 'paid']);
    }

    public function test_failed_payment_is_marked_failed(): void
    {
        $this->airtelStatus = 'TF';
        $id = $this->postJson('/api/payments/airtel', ['order_id' => '1002', 'amount' => 500, 'phone' => '+256752123456'])->json('transaction_id');
        $this->postJson('/api/webhooks/airtel', ['transaction' => ['id' => $id]]);
        $this->assertSame('failed', AirtelPayment::first()->status);
    }

    public function test_a_settled_payment_is_never_changed_again(): void
    {
        $this->airtelStatus = 'TS';
        $id = $this->postJson('/api/payments/airtel', ['order_id' => '1004', 'amount' => 500, 'phone' => '0752123456'])->json('transaction_id');
        $this->postJson('/api/webhooks/airtel', ['transaction' => ['id' => $id]]);

        $this->airtelStatus = 'TF'; // a late duplicate callback, and a different answer
        $this->postJson('/api/webhooks/airtel', ['transaction' => ['id' => $id]]);
        $this->assertSame('paid', AirtelPayment::first()->status);
    }

    public function test_timeout_keeps_the_payment_pending(): void
    {
        Http::fake([
            '*/auth/oauth2/token' => Http::response(['access_token' => 'test-token', 'expires_in' => 3600]),
            '*/merchant/v1/payments/' => fn () => throw new ConnectionException('timed out'),
        ]);

        $this->postJson('/api/payments/airtel', ['order_id' => '1003', 'amount' => 500, 'phone' => '0752123456'])->assertStatus(202);
        $this->assertSame('pending', AirtelPayment::first()->status);
    }

    public function test_callbacks_for_unknown_transactions_are_ignored(): void
    {
        $this->postJson('/api/webhooks/airtel', ['transaction' => ['id' => 'not-ours', 'status_code' => 'TS']])->assertOk();
        Http::assertNotSent(fn (Request $request) => str_contains($request->url(), '/standard/v1/payments/'));
    }
}

Run it with php artisan test --filter=AirtelPaymentTest.

Common errors and fixes

Problem Likely cause Fix
Invalid MSISDN (developers report DP00800001006) Number sent with the country code or a leading 0 Send 752123456, not 256752123456 or 0752123456
401 on payment or status Expired token, or UAT keys used against production The cached token refreshes a minute early. Check AIRTEL_ENV matches your keys
Payment request accepted but no prompt Wrong AIRTEL_COUNTRY or AIRTEL_CURRENCY for the number Use your market’s values, for example UG and UGX
419 on the callback Route is in routes/web.php Move it to routes/api.php
Callback never arrives Callback URL not registered, or not public HTTPS Register it on the application in the developer portal; the scheduled check settles payments meanwhile
Status stays TA Airtel hasn’t resolved the payment yet Keep it pending; the job keeps asking. Check your Airtel statement if it lasts a day

Going to production

When Airtel approves your application for production:

  • Set AIRTEL_ENV=production and use your production client ID and secret.
  • Register the production callback URL (HTTPS) on the production application.
  • Check whether your app is enabled for /merchant/v1/payments/ or the newer v2 endpoint, which encrypts the payload. This guide covers v1.
  • Run php artisan config:cache, and keep the queue worker and scheduler running under a process manager such as Supervisor. Use a shared cache store if you run several servers.
  • Reconcile daily: compare your paid payments with the Airtel Money statement, especially any that went through TA.

Want all of this already built, tested and combined with MTN MoMo and M-Pesa? The Laravel Mobile Money Starter Kit has it ready.

Frequently asked questions

Where do I set the Airtel Money callback URL?

In the Airtel developer portal, on your application. Unlike MTN MoMo, Airtel does not take a callback URL in each payment request, so every callback for the app goes to the URL you registered.

Why does my Airtel callback route return 419 in Laravel?

419 is Laravel's CSRF protection, because Airtel cannot send a CSRF token. Put the callback in routes/api.php, which has no CSRF check (run php artisan install:api if the file doesn't exist).

Can I trust the status in an Airtel Money callback?

Use it as a signal, not proof. Anyone who finds your callback URL can post to it. When a callback arrives, call GET /standard/v1/payments/{id} and act on that response, as this guide's job does.

What does Airtel Money status TA mean?

TA means ambiguous: Airtel is not sure yet whether the payment went through. Keep the payment pending and query again later. Don't mark it failed or let the customer pay a second time.

Why does Airtel reject my phone number?

subscriber.msisdn must be the number without the country code and without the leading 0, for example 752123456 in Uganda. Sending 256752123456 or 0752123456 is a common cause of invalid MSISDN errors.

Need this built for you?

I integrate MTN MoMo, M-Pesa and Airtel Money into Laravel / PHP, Python / Django, Node.js / Next.js apps: payments, callbacks, payouts and go-live, with tests.