Skip to content

PHP

The wajub/wajub-php package, its resources, and how it fits a Laravel app.

wajub/wajub-php is the server side of a Wajub integration. It holds your secret key, creates payments, reads their real status and verifies webhook signatures. Guzzle underneath, PSR-4 on top, no framework assumed.

wajub/wajub-php

Stable · GA

Packagist

Version
2.0.0
Runtime
PHP 8.4+, Guzzle 8.2+, ext-json

Covers

  • Payments
  • Billing
  • Transfers
  • Sync
  • Shield
  • Tax

Install

One package, two dependencies
composer require wajub/wajub-php

Create the client

The constructor takes an array. api_key has no environment fallback, so read the variable yourself. Only webhook_secret falls back to WAJUB_WEBHOOK_SECRET when omitted.

Plain PHP
<?php

use Wajub\Wajub;

$wajub = new Wajub([
    'api_key' => getenv('WAJUB_API_KEY'),
    'webhook_secret' => getenv('WAJUB_WEBHOOK_SECRET'),
]);
KeyWhat it does
api_keyYour sk. or sk_test. key. Required, an empty value throws
webhook_secretThe whsec_ secret, needed only for webhooks->constructEvent(). Defaults to WAJUB_WEBHOOK_SECRET
idempotency_key_prefixPrefix of the generated key. Defaults to 'wajub'
http_clientYour own GuzzleHttp\Client, for a proxy or a mocked handler in tests
max_network_retriesRetries on transient failures. Defaults to 2
timeoutSeconds, passed to the default Guzzle client. Defaults to 30

A missing key raises InvalidArgumentException: Wajub: api_key is required at construction, so a bad configuration fails at boot rather than on the first payment.

In a Laravel application

Bind it once as a singleton and inject it. That keeps one Guzzle connection pool for the whole process.

<?php
// app/Providers/AppServiceProvider.php

use Wajub\Wajub;

public function register(): void
{
    $this->app->singleton(Wajub::class, fn () => new Wajub([
        'api_key' => config('services.wajub.secret'),
        'webhook_secret' => config('services.wajub.webhook_secret'),
    ]));
}

In Laravel, add the two keys to config/services.php rather than calling env() outside a config file, or php artisan config:cache will hand you a null key in production.

config/services.php
'wajub' => [
    'secret' => env('WAJUB_API_KEY'),
    'webhook_secret' => env('WAJUB_WEBHOOK_SECRET'),
],

The first call

Create a payment
$payment = $wajub->payments->create([
    'amount' => 25000,
    'currency' => 'XAF',
    'email' => 'amina@example.com',
    'description' => 'Order 4172',
    'reference' => 'order-4172',
    'callback' => route('checkout.complete'),
]);

return redirect()->away($payment->authorization_url);

Parameters go in as an associative array with the API's exact field names. What comes back is an ApiObject, so every field is a property and nothing is lost.

Reading the result
$payment->id;                   // trx_test_8kQ2mW9vB4nL6hR1cY3d
$payment->status;               // pending
$payment->authorization_url;    // https://pay.wajub.com/tok_xxxxx
$payment->authorization_token;  // tok_xxxxx

// A field added by the API after this release is still readable.
$payment->settlement_batch_id;

Amounts are in the major unit

25000 with XAF is twenty-five thousand francs. A decimal currency takes a decimal: 'amount' => 12.50 with GHS.

Every resource on the client

PropertyMethods
$wajub->globalping, channels, countries, currencies
$wajub->paymentscreate, initialize, retrieve, list, cancel, process, processSplit, listRefunds
$wajub->customerscreate, retrieve, update, delete, list, block, unblock, activate, deactivate, listTaxIds, createTaxId, deleteTaxId
$wajub->refundscreate, retrieve, list
$wajub->transferscreate, retrieve, list
$wajub->beneficiariescreate, retrieve, update, delete, list
$wajub->linkscreate, retrieve, update, delete, list
$wajub->invoicescreate, retrieve, update, delete, list, send, markPaid, cancel
$wajub->accountscreate, retrieve, update, delete, list, regenerateToken
$wajub->webhookEndpointscreate, retrieve, update, delete, list, rotateSecret
$wajub->balanceretrieve
$wajub->eventslist, retrieve, resend
$wajub->disputeslist, retrieve, submitEvidence, accept, close, sendMessage
$wajub->identityresolve, validate
$wajub->taxgetSettings, updateSettings, rates, calculate, reports, listCodes, retrieveCode, listRegistrations, createRegistration, retrieveRegistration, updateRegistration, deleteRegistration, jurisdictions, thresholds, thresholdAlerts
$wajub->shieldgetSettings, updateSettings, stats, listBlocklist, addToBlocklist, removeFromBlocklist
$wajub->listenconfig, auth
$wajub->webhooksconstructEvent
  • webhooks signature verification runs locally, no HTTP call. All other resources call the merchant REST API.
  • links, invoices, tax and shield are live only. A sandbox key gets 403 This feature is only available in live mode. on every one of their methods.
  • refunds and transfers are create, retrieve and list only. The shared CRUD base also exposes update and delete on them, but the API serves no such route.

Paging through a list

list() returns a PagedResult, which implements Iterator, so foreach over the object walks the current page and autoPagingIterator() walks all of them.

One page, or all of them
$page = $wajub->payments->list(['status' => 'success', 'per_page' => 50]);

foreach ($page->data as $payment) {
    echo $payment['id'].' '.$payment['amount'];
}

if ($page->hasMore) {
    $page = $page->getNextPage();
}

// Or let it fetch the following pages for you.
foreach ($wajub->payments->list()->autoPagingIterator() as $payment) {
    $this->reconcile($payment);
}

$page->data holds plain arrays, not ApiObject, so rows use array access. $page->meta carries total, per_page, current_page and last_page when the endpoint returns them.

Idempotency and retries

Every POST and PUT carries a generated Idempotency-Key, which is what makes the automatic retry safe. Pass your own whenever you have a natural one.

An order number is the best key
use Wajub\RequestOptions;

$wajub->payments->create(
    $params,
    new RequestOptions(idempotencyKey: "order-{$order->id}"),
);
WhatValue
Retried statuses429, 500, 502, 503, 504, and any network failure
Attempts3 in total, one original plus two retries
BackoffExponential with jitter, honouring Retry-After when present, never more than 10 seconds
Timeout30 seconds per request

Acting for a connected account

One call, one seller
use Wajub\RequestOptions;

$wajub->payments->create(
    ['amount' => 25000, 'currency' => 'XAF', 'email' => $buyer->email],
    new RequestOptions(sync: $seller->wajub_account_id),
);

sync becomes the X-Sync header. Setup is on Sync.

Webhooks

constructEvent verifies the signature and returns the parsed event. It needs the body exactly as it arrived.

A Laravel controller
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Wajub\Exception\WebhookSignatureVerificationError;
use Wajub\Wajub;

class WajubWebhookController extends Controller
{
    public function __invoke(Request $request, Wajub $wajub)
    {
        try {
            $event = $wajub->webhooks->constructEvent(
                $request->getContent(),
                $request->header('X-Wajub-Signature', ''),
                $request->header('X-Wajub-Timestamp', ''),
            );
        } catch (WebhookSignatureVerificationError) {
            return response()->noContent(400);
        }

        if ($event['event'] === 'payment.succeeded') {
            HandleWajubEvent::dispatch($event);
        }

        return response()->noContent(200);
    }
}

Two more things a Laravel webhook route needs. Exempt it from CSRF, since Wajub has no session token, and answer fast. Acknowledge with 200 and let a queued job do the work, because the delivery is retried when your endpoint takes too long.

bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
    $middleware->validateCsrfTokens(except: ['webhooks/wajub']);
})

The event name is in event, not in type. Tolerance is 300 seconds by default, and constructEvent takes a fourth argument if your clocks drift further. Details on Signature verification.

Errors

Catch the specific one first
use Wajub\Exception\InvalidRequestException;
use Wajub\Exception\RateLimitException;
use Wajub\Exception\WajubError;

try {
    $wajub->payments->create($params);
} catch (InvalidRequestException $e) {
    // $e->errors is ['amount' => 'The amount must be at least 25.']
    return response()->json(['fields' => $e->errors], 422);
} catch (RateLimitException $e) {
    return response()->noContent(503)
        ->header('Retry-After', (string) ($e->retryAfter ?? 5));
} catch (WajubError $e) {
    Log::error('wajub failed', ['code' => $e->errorCode, 'status' => $e->httpStatus]);
    throw $e;
}
Class in Wajub\ExceptionRaised on
AuthenticationException401
PermissionException403
NotFoundException404
InvalidRequestException400 and 422
RateLimitException429, with retryAfter in seconds
WajubErrorEvery other status, and the parent of all of the above
ApiConnectionExceptionNo response at all, network or timeout
WebhookSignatureVerificationErrorA webhook that did not verify

Testing without touching the API

The http_client option takes any Guzzle client, so a mock handler makes the whole SDK offline.

A PHPUnit test with no network
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
use Wajub\Wajub;

$mock = new MockHandler([
    new Response(201, [], json_encode([
        'code' => 201,
        'authorization_url' => 'https://pay.wajub.com/tok_test',
        'authorization_token' => 'tok_test',
        'transaction' => ['id' => 'trx_test', 'status' => 'pending', 'amount' => 25000],
    ])),
]);

$wajub = new Wajub([
    'api_key' => 'sk_test.fake',
    'http_client' => new Client(['handler' => HandlerStack::create($mock)]),
]);

$payment = $wajub->payments->create(['amount' => 25000, 'currency' => 'XAF']);

$this->assertSame('tok_test', $payment->authorization_token);

Charging without the hosted page

Mobile Money push
$payment = $wajub->payments->create([
    'amount' => 25000,
    'currency' => 'XAF',
    'phone' => '+237670000000',
]);

$wajub->payments->process($payment->id, [
    'channel' => 'cm.mtn',
    'phone' => '+237670000000',
]);

A channel is country.operator, so cm.mtn is MTN Mobile Money in Cameroon. The full list is on Payment methods. The payer still approves on their handset, so the outcome arrives on the webhook.

Paying out

A transfer to a phone number
use Wajub\RequestOptions;

$transfer = $wajub->transfers->create(
    [
        'amount' => 100000,
        'currency' => 'XAF',
        'beneficiary' => [
            'name' => 'Amina Diallo',
            'channel' => 'cm.mtn',
            'phone' => '+237670000000',
        ],
        'reference' => 'payout-892',
    ],
    new RequestOptions(idempotencyKey: 'payout-892'),
);

beneficiary also takes the ben_… id of a saved beneficiary, which is the better shape once you pay the same person twice.

Local development

Two terminals
wajub listen --forward-to localhost:8000/webhooks/wajub
wajub trigger payment.succeeded

More on the CLI.

What did you think of this content?