Skip to content

PHP

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

wajub/wajub-php is your server's Wajub client: with your secret key, it creates payments, reads their status and verifies webhook signatures, with or without a framework.

wajub/wajub-php

Stable · GA

Packagist

Version
2.0.1
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
http_clientYour own GuzzleHttp\Client, for a proxy or a mocked handler in tests. Give a real one base_uri https://api.wajub.com and http_errors false
max_network_retriesHow many times a temporary failure is retried
timeoutRequest timeout in seconds, when you pass no http_client

In a Laravel application

Bind it once as a singleton and inject it.

<?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/test_xxxxx
$payment->authorization_token;  // test_xxxxx

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, 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->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.

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' => 'succeeded', '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 the list's pagination fields, described on Pagination.

Idempotency and retries

Temporary failures are retried for you; max_network_retries changes how many times. To make a create safe to repeat on your side too, after a restart or in a queued job's retry, pass your own idempotency key, the order number for instance.

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

$wajub->payments->create(
    $params,
    new RequestOptions(idempotencyKey: "order-{$order->id}"),
);

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),
);

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. If your clocks drift, constructEvent takes the tolerance in seconds as a fourth argument. 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/test_xxxxx',
        'authorization_token' => 'test_xxxxx',
        '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('test_xxxxx', $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 Mobile Money. 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?