Payroll platform
Pay hundreds of salaries or allowances straight to Orange Money wallets by looping over POST /v1/payouts. For very large volumes (batch), contact support to enable the dedicated feature on your account.
Architecture
[Your payroll]
|
| Loop over POST /v1/payouts (1 request per salary, with an Idempotency-Key)
v
[SOAMTAR]
|
|-> payout 1 : Orange Money SL wallet
|-> payout 2 : Orange Money SL wallet
|-> payout 3 : Orange Money SL wallet
'-> ...Models
php
Schema::create('payroll_runs', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->string('reference'); // PAY-2026-04
$table->date('period');
$table->decimal('total_sle', 15, 2);
$table->string('status'); // draft, processing, completed, failed
$table->timestamps();
});
Schema::create('payroll_items', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignUuid('payroll_run_id');
$table->foreignUuid('employee_id');
$table->string('phone'); // +232XXXXXXXX
$table->decimal('amount_sle', 15, 2);
$table->string('soamtar_payout_id')->nullable();
$table->string('status'); // pending, success, failed
$table->text('error')->nullable();
$table->timestamps();
});Creating the payouts
php
class PayrollService
{
public function __construct(private SoamtarClient $soamtar) {}
public function run(PayrollRun $run): void
{
foreach ($run->items as $item) {
try {
$response = $this->soamtar->payouts()->create([
'merchant_ref' => "PAY-{$run->id}-{$item->id}",
'amount' => $item->amount_sle,
'currency' => 'SLE',
'provider' => 'orange_money_sl', // the only channel in Sierra Leone
'recipient' => [
'phone' => $item->phone,
'name' => $item->employee->full_name,
],
'description' => "Salary {$run->period->format('Y-m')} for {$item->employee->full_name}",
'metadata' => ['payroll_item_id' => (string) $item->id],
], idempotencyKey: "payroll:{$item->id}");
$item->update([
'status' => 'pending',
'soamtar_payout_id' => $response['data']['id'],
]);
} catch (SoamtarException $e) {
$item->update(['status' => 'failed', 'error' => $e->getMessage()]);
}
}
$run->update(['status' => 'processing']);
}
}Webhook handler
php
public function handle(Request $request, WebhookVerifier $verifier)
{
$event = $verifier->verify($request);
$itemId = $event['data']['metadata']['payroll_item_id'] ?? null;
$item = PayrollItem::find($itemId);
if (! $item) return response('skip');
match ($event['event']) {
'payout.success' => $item->update(['status' => 'success']),
'payout.failed' => $item->update(['status' => 'failed', 'error' => 'Payout failed']),
default => null,
};
$run = $item->run;
if ($run->items()->where('status', 'pending')->doesntExist()) {
$run->update([
'status' => $run->items()->where('status', 'failed')->exists()
? 'partially_completed'
: 'completed',
]);
}
return response('ok');
}Retrying failures
php
class RetryFailedPayouts extends Command
{
public function handle(SoamtarClient $soamtar)
{
PayrollItem::where('status', 'failed')
->where('updated_at', '<', now()->subMinutes(30))
->each(function (PayrollItem $item) use ($soamtar) {
$day = now()->format('Ymd');
try {
$soamtar->payouts()->create([
'merchant_ref' => "RETRY-{$item->id}-{$day}",
'amount' => $item->amount_sle,
'currency' => 'SLE',
'provider' => 'orange_money_sl',
'recipient' => [
'phone' => $item->phone,
'name' => $item->employee->full_name,
],
'metadata' => ['payroll_item_id' => (string) $item->id, 'retry' => true],
], idempotencyKey: "retry:{$item->id}:{$day}");
$item->update(['status' => 'pending', 'error' => null]);
} catch (SoamtarException $e) {
$item->update(['error' => $e->getMessage()]);
}
});
}
}Audit
Export for accounting:
php
PayrollItem::with(['employee', 'run'])
->where('payroll_run_id', $run->id)
->get()
->map(fn ($i) => [
'Employee' => $i->employee->full_name,
'Phone' => $i->phone,
'Amount (SLE)' => $i->amount_sle,
'Status' => $i->status,
'SOAMTAR reference' => $i->soamtar_payout_id,
'Error' => $i->error,
]);Recommendations
- Approval workflow: two people approve every run above an internal threshold
- Schedule runs on a working-day morning (Freetown time)
- Daily retry of
faileditems - Check your SOAMTAR balance covers the whole run before starting it
- Dashboard: success rate per period