Skip to content

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 failed items
  • Check your SOAMTAR balance covers the whole run before starting it
  • Dashboard: success rate per period

SOAMTAR Payment API Documentation.