Samen Steeve
MY SERVICES.
Back to blog
LaravelPaymentsMeSombMobile MoneyEscrowSecurity
September 2, 20269 min read

Integrating mobile money (MeSomb) into a legaltech platform: an escrow that holds

Integrating a payment gateway is the rarest feature where a bug isn't an annoyance, it's money leaving the system. On TribuneJustice, a legaltech where clients pay lawyers before the service is delivered, the stakes were higher: we needed an escrowflow, not just a “charge the card” button.

This is what integrating mobile money in Cameroon (MeSomb, via MTN Mobile Money and Orange Money) looked like, and the engineering that stops a payment stack from leaking money.

Why payments are a legaltech's riskiest code

On a legal platform, a payment is not “buy a product”. It's: a client puts money in trust, a lawyer performs a service, the funds are released. That means the payment engine carries:

  • An escrow lifecycle (held, released, refunded) that must never skip a state.
  • Two parties (client and expert) plus a platform commission in a single transaction.
  • The requirement to handle failures, retries, and double-notifications without ever crediting twice.

If a standard SaaS can tolerate “the payment went through twice, we'll refund later”, a legal escrow cannot. Let's look at how I built it.

One abstraction, three providers

The first decision: never hard-code a provider. Every payment touches a gateway through a single interface, so swapping or adding a PSP never touches business logic.

interface PaymentGatewayInterface
{
    public function getGatewayName(): string;
    public function initiatePayment(PaymentInitDTO $dto, string $internalReference): PaymentResultDTO;
    public function verifyWebhook(Request $request): bool;
    public function handleWebhook(Request $request): PaymentResultDTO;
    public function checkStatus(string $paymentReference): PaymentResultDTO;
}

A router picks the right implementation per context: MeSomb is the default aggregator for all mobile money in XAF (MTN MoMo, Orange Money), while CinetPay/Flutterwave handle cards and international payments.

if (in_array($method, [Transaction::METHOD_MTN_MOMO, Transaction::METHOD_ORANGE_MONEY], true)) {
    return $this->mesomb;   // mobile money (XAF) → MeSomb
}
return $this->cinetPay;     // cards & international → CinetPay / Flutterwave
The abstraction is the whole point. When a gateway changes its API or a new aggregator appears, you add one adapter, you don't rewrite the escrow.

The hard part: signing requests like the SDK does

MeSomb doesn't use a simple API key. Every request carries an Authorization header built from an HMAC-SHA1 signature over a canonical request. The official PHP SDK (hachther/mesomb-php) constructs it in a veryspecific way, and if your header insertion order doesn't match exactly, every request is rejected with a 401.

The subtle details that make or break it:

  • Headers must be inserted in the exact order: content-type (for non-GET), host, x-mesomb-date, x-mesomb-nonce.
  • The canonical request joins the path segments rawurlencoded and hashes the body with sha1.
  • serialize_precision must be set to -1 so PHP's JSON serialization doesn't produce subtly different floats.
// The scope and string-to-sign, rebuilt line-by-line from the SDK
$scope = $date->format('Ymd').'/payment/mesomb_request';
$stringToSign = 'HMAC-SHA1'."\n".$timestamp."\n".$scope."\n".sha1($canonicalRequest);
$signature = hash_hmac('sha1', $stringToSign, $this->secretKey);
This is the kind of code where you can't “wing it”. I rebuilt the signing by reading the SDK source line by line, the method comment even notes the exact insertion order copied from AOperation::executeRequest and Signature::signRequest. That attention is what turns an opaque 401 into a working payment.

Two more real-world traps with Cameroonian phone numbers:

  • Users type their number in a dozen formats (+237677123456, 00237 677..., 67712 34 56). I strip everything non-digit, drop the 237 prefix, and keep the 9 local digits.
  • The operator is inferred from the prefix (69, 655, 656: Orange; otherwise MTN), because the API needs to know which wallet to debit.

Idempotence: the bug that double-charges

The signature of every payment engine is a race condition. Consider a confirmation path where a webhook and a manual status check both arrive for the same transaction, or where the PSP retries a notification. Without protection, two concurrent requests both pass the if ($tx->status !== 'completed') guard, and you generate two invoices and mark the service as paid twice.

The fix is pessimistic locking inside the transaction:

return DB::transaction(function () use ($transaction) {
    $lockedTx = Transaction::lockForUpdate()->findOrFail($transaction->id);

    if ($lockedTx->status === Transaction::STATUS_COMPLETED) {
        return $lockedTx; // already handled → idempotent
    }

    $invoice = Invoice::create([...]);
    $lockedTx->update(['status' => Transaction::STATUS_COMPLETED, ...]);
    // ... mark the ServiceRequest as paid
});
lockForUpdate() takes a row lock so a concurrent transaction waits, re-reads the fresh state, and sees it's already completed. Without it, two requests hammering the same millisecond both pass the check, and money flows twice. This is invisible in unit tests and nearly impossible to reproduce by hand.

The same pattern guards every money-mutating transition: escrow release (early-return if already released) and refund (early-return if already refunded).

The escrow lifecycle

Every transaction starts pending with escrow_status = held. The funds are in trust, not with the expert.

'escrow_status' => Transaction::ESCROW_HELD,
'status'        => Transaction::STATUS_PENDING,

When the service is delivered, the escrow is released and the expert payout is scheduled at J+7 (an owner decision: give a cooling-off window). The platform commission (~20%) is computed up front and the net amount goes to the expert as a pending payout.

$scheduledAt = $now->copy()->addDays(self::PAYOUT_DELAY_DAYS); // 7

$lockedTx->update([
    'escrow_status' => Transaction::ESCROW_RELEASED,
    'payout_due_at' => $scheduledAt,
]);

ProfessionalPayout::updateOrCreate([...], [
    'commission_amount' => $commissionAmount,
    'net_amount'        => $netAmount,
    'status'            => 'pending',
    'scheduled_at'      => $scheduledAt,
]);

Each state transition is validated and immutable: you can't release funds from an already-refunded escrow, and the refund path is its own state machine.

Refunds as a state machine (ADR-001)

Refunds aren't one button. They're scenarios with different economics:

  • Full refund (cancellation before start): 100% of the gross paid, PSP fees absorbed by the platform.
  • Partial refund (cancellation after start): a percentage entered case by case by an admin.
  • Dispute: 100% of the gross plus recovery of the platform commission from the expert, either debited from their internal balance or clawed back as a negative net payout (net_amount negative) absorbed on future payouts.
Idempotent refund

The refund locks the row and early-returns if already refunded, a retry never refunds twice.

Best-effort PSP refund

If the gateway refund fails, the record stays pending and is retried, rather than failing the whole operation.

Expert clawback

In a dispute, the platform recovers its commission: debit the balance first, then a negative due payout on future earnings.

Business state stays consistent

On refund, the service request is cancelled, the invoice marked refunded, and the timeline updated. No orphaned states.

Webhooks: the part that breaks in production

Webhooks are where the money gets confirmed, and they're full of traps.

Signature verification with replay protection. MeSomb sends an X-MeSomb-Webhook-Signature header in the format t=<timestamp>,v1=<signature>. I verify it with HMAC-SHA256, reject anything outside a 10-minute timestamp window (anti-replay), and compare with hash_equals (constant-time, against timing attacks).

if (abs(time() - $timestamp) > 600) {
    return false; // replay attempt
}
$payloadToSign = "{$timestamp}.{$rawBody}";
$expectedSignature = hash_hmac('sha256', $payloadToSign, $secret);
return hash_equals($expectedSignature, $receivedSignature);

Payload formats change under you. The gateway evolved from a legacy {status, pk, reference} payload to a new nested {event_type, data.object}shape. The handler normalizes both, the webhook processor has to survive your provider's API drift.

A logging bug that could take payments down. The production log channel (Nightwatch) occasionally had a file-permission issue. Any Log:: call that threw inside the webhook handler would make the whole webhook fail with a 500, which the PSP would retry, repeatedly, potentially blocking legitimate payments. The fix: wrap the log in a best-effort try/catch so a log failure can never fail a payment.

try {
    Log::info("Webhook received ({$gatewayName})", ['payload' => $request->all()]);
} catch (\Throwable $logE) {
    // silent, a log write failure must never block a payment
}

What this taught me

Payments demand a specific engineering temperament. The interesting bugs aren't on happy paths, they're in retries, races, and provider drift. Three rules I now apply everywhere:

1. Lock every money transition. lockForUpdate() + idempotent early-return on every state change. 2. Never let a side effect fail a payment. Logging, notifications, or a secondary call should be best-effort, not blocking. 3. Treat provider APIs as hostile contracts. Verify signatures, tolerate replay-window drift, normalize multi-format payloads, and read the SDK source when a request returns a cryptic 401.

For a legaltech, where trust isthe product, the escrow isn't a feature. It's the whole point. And it only works if the code underneath refuses to let money flow twice, or to let a log line take a payment down.

Need an offensive security audit?

Let's uncover your logical bypasses, BOLA, and SSRF vulnerabilities before they are exploited.

Start a project