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 · GAPackagist
- Version
- 2.0.0
- Runtime
- PHP 8.4+, Guzzle 8.2+, ext-json
Covers
- Payments
- Billing
- Transfers
- Sync
- Shield
- Tax
Install
composer require wajub/wajub-phpCreate 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.
<?php
use Wajub\Wajub;
$wajub = new Wajub([
'api_key' => getenv('WAJUB_API_KEY'),
'webhook_secret' => getenv('WAJUB_WEBHOOK_SECRET'),
]);| Key | What it does |
|---|---|
api_key | Your sk. or sk_test. key. Required, an empty value throws |
webhook_secret | The whsec_ secret, needed only for webhooks->constructEvent(). Defaults to WAJUB_WEBHOOK_SECRET |
idempotency_key_prefix | Prefix of the generated key. Defaults to 'wajub' |
http_client | Your own GuzzleHttp\Client, for a proxy or a mocked handler in tests |
max_network_retries | Retries on transient failures. Defaults to 2 |
timeout | Seconds, 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.
'wajub' => [
'secret' => env('WAJUB_API_KEY'),
'webhook_secret' => env('WAJUB_WEBHOOK_SECRET'),
],The first call
$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.
$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
| Property | Methods |
|---|---|
| $wajub->global | ping, channels, countries, currencies |
| $wajub->payments | create, initialize, retrieve, list, cancel, process, processSplit, listRefunds |
| $wajub->customers | create, retrieve, update, delete, list, block, unblock, activate, deactivate, listTaxIds, createTaxId, deleteTaxId |
| $wajub->refunds | create, retrieve, list |
| $wajub->transfers | create, retrieve, list |
| $wajub->beneficiaries | create, retrieve, update, delete, list |
| $wajub->links | create, retrieve, update, delete, list |
| $wajub->invoices | create, retrieve, update, delete, list, send, markPaid, cancel |
| $wajub->accounts | create, retrieve, update, delete, list, regenerateToken |
| $wajub->webhookEndpoints | create, retrieve, update, delete, list, rotateSecret |
| $wajub->balance | retrieve |
| $wajub->events | list, retrieve, resend |
| $wajub->disputes | list, retrieve, submitEvidence, accept, close, sendMessage |
| $wajub->identity | resolve, validate |
| $wajub->tax | getSettings, updateSettings, rates, calculate, reports, listCodes, retrieveCode, listRegistrations, createRegistration, retrieveRegistration, updateRegistration, deleteRegistration, jurisdictions, thresholds, thresholdAlerts |
| $wajub->shield | getSettings, updateSettings, stats, listBlocklist, addToBlocklist, removeFromBlocklist |
| $wajub->listen | config, auth |
| $wajub->webhooks | constructEvent |
webhookssignature verification runs locally, no HTTP call. All other resources call the merchant REST API.links,invoices,taxandshieldare live only. A sandbox key gets403 This feature is only available in live mode.on every one of their methods.refundsandtransfersare create, retrieve and list only. The shared CRUD base also exposesupdateanddeleteon 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.
$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.
use Wajub\RequestOptions;
$wajub->payments->create(
$params,
new RequestOptions(idempotencyKey: "order-{$order->id}"),
);| What | Value |
|---|---|
| Retried statuses | 429, 500, 502, 503, 504, and any network failure |
| Attempts | 3 in total, one original plus two retries |
| Backoff | Exponential with jitter, honouring Retry-After when present, never more than 10 seconds |
| Timeout | 30 seconds per request |
A queued job is a better retry than a synchronous one
Payment creation inside a web request holds a PHP-FPM worker for up to 30 seconds when the API is slow, and three of those exhaust a small pool. Dispatch the call from a queued job and give the job the same idempotency key, so a job retry lands on the same payment instead of creating a second one.
Acting for a connected account
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.
<?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);
}
}Use getContent, never all or json
The signature covers {timestamp}.{raw body}. $request->all() and $request->json() both
parse and would have to be re-encoded, which changes key order and spacing and breaks the hash.
$request->getContent() returns the bytes as delivered.
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.
->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
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\Exception | Raised on |
|---|---|
AuthenticationException | 401 |
PermissionException | 403 |
NotFoundException | 404 |
InvalidRequestException | 400 and 422 |
RateLimitException | 429, with retryAfter in seconds |
WajubError | Every other status, and the parent of all of the above |
ApiConnectionException | No response at all, network or timeout |
WebhookSignatureVerificationError | A webhook that did not verify |
getCode always returns zero
WajubError passes only the message up to Exception, so $e->getCode() is 0 on every Wajub
failure. The real value lives on the readonly property $e->errorCode, beside $e->httpStatus,
$e->errors and $e->raw. The base class is also the one name in the hierarchy that ends in
Error rather than Exception.
Testing without touching the API
The http_client option takes any Guzzle client, so a mock handler makes the whole SDK offline.
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
$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
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
wajub listen --forward-to localhost:8000/webhooks/wajub
wajub trigger payment.succeededMore on the CLI.
Related pages
- SDK QuickstartThe same first call, in five languages side by side.
- WebhooksEvery event, and the delivery guarantees behind them.
- Wajub.jsThe browser half, mounted with the token this SDK returns.
- IdempotencyWhat a key protects and for how long.
- Error handlingWhich failures to retry and which to surface.
- API referenceThe endpoints behind every method above.