The problem: six gateways, six dialects#

A MENA store rarely has the luxury of one payment provider. Cards might run through Paymob or Tap, HyperPay covers another acquirer, Fawry collects cash at kiosks, and Tabby or Tamara handle BNPL. Each has its own API shape, its own status strings, its own webhook format - and its own way of telling you the money actually moved.

Without a boundary, gateway if/else spreads through checkout, order management, refunds and reporting. Then marketing asks to add a second BNPL provider and you rewrite everything.

The fix is a classic: an adapter per provider behind one interface, plus one state machine that your whole system speaks.

Payment state machine diagram: pending transitions to authorized, paid or failed; authorized to paid, failed on capture error, or voided on void/expire; paid to partially refunded or refunded; partially refunded to refunded; failed, voided and refunded are terminal
One payment state machine for every gateway

One interface, one adapter per provider#

PaymentGateway.phpPHP
interface PaymentGateway
{
    public function name(): string;

    /**
     * Maps one verified provider callback payload onto our unified state.
     * Anything unrecognized throws - unknown NEVER becomes "failed"; it is
     * quarantined for a human. Money state moves only on statuses verified
     * in the provider's current documentation.
     */
    public function normalizeCallback(array $payload): PaymentState;
}

The critical design rule: unknown is not failed. A default => Failed mapping turns every status the provider adds or renames next quarter into a lost payment and a support ticket. Adapters map only statuses verified in the provider's current docs and throw on everything else; the webhook handler catches that and parks the event in a quarantine table for a human.

The adapter is the only place that knows Paymob sends success=true or that Tap talks about charges. Your order code only ever sees PaymentState.

The state machine, tested#

One enum defines every state a payment can be in, and - critically - which transitions are legal. paid can go to refunded, never back to pending. voided deliberately groups the provider-specific ways an authorization dies (void, expiry, customer abandonment) - a simplification you may split later if your reconciliation needs them separate.

PaymentState.phpPHP
enum PaymentState: string
{
    case Pending = 'pending';
    case Authorized = 'authorized';
    case Paid = 'paid';
    case Failed = 'failed';
    case Voided = 'voided';
    case Refunded = 'refunded';
    case PartiallyRefunded = 'partially_refunded';

    public function canTransitionTo(self $next): bool
    {
        return match ($this) {
            self::Pending    => in_array($next, [self::Authorized, self::Paid, self::Failed], true),
            // void/expire releases the authorization - no money ever moved,
            // so it is its own terminal state, not a payment failure.
            self::Authorized => in_array($next, [self::Paid, self::Failed, self::Voided], true),
            self::Paid       => in_array($next, [self::Refunded, self::PartiallyRefunded], true),
            self::PartiallyRefunded => $next === self::Refunded,
            self::Failed, self::Voided, self::Refunded => false, // terminal
        };
    }
}
TapAdapter.phpPHP
final class TapAdapter implements PaymentGateway
{
    public function name(): string { return 'tap'; }

    public function normalizeCallback(array $payload): PaymentState
    {
        $raw = strtoupper((string)($payload['status'] ?? ''));
        return match ($raw) {
            // Only statuses documented for Tap charges/webhooks are mapped.
            'CAPTURED'   => PaymentState::Paid,
            'AUTHORIZED' => PaymentState::Authorized,
            'INITIATED'  => PaymentState::Pending,
            'VOID'       => PaymentState::Voided,
            // New, renamed or unverified statuses are quarantined, not guessed.
            default => throw new UnexpectedValueException("Unrecognized Tap status: {$raw}"),
        };
    }
}

Paymob has no single status string at all - its transaction callback carries boolean fields (obj.success, obj.pending, obj.error_occured, plus transaction-type flags like is_auth, is_capture, is_refund, is_void; the query-string variant sends them as 'true'/'false' strings). Two traps hide here. First, success=true is not a purchase by itself: authorization, capture, refund and void events all carry it, so the adapter validates the type flags and only maps a verified purchase/capture to paid. Second, filter_var($missing) quietly returns false rather than null - so fields are checked for existence and type before filtering, and anything missing or non-boolean is rejected. The caller passes the HMAC-verified, schema-validated obj - never the raw envelope, never unverified input:

PaymobAdapter.phpPHP
final class PaymobAdapter implements PaymentGateway
{
    public function name(): string { return 'paymob'; }

    public function normalizeCallback(array $payload): PaymentState
    {
        $b = function ($k) use ($payload) {
            if (! array_key_exists($k, $payload)) return null;       // missing -> reject
            $v = $payload[$k];
            if (! is_bool($v) && ! is_string($v)) return null;       // wrong type -> reject
            if ($v === '') return null;                              // empty -> reject
            return filter_var($v, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE);
        };
        foreach (['success', 'pending', 'error_occured', 'is_auth', 'is_capture',
                  'is_standalone_payment', 'is_refund', 'is_refunded', 'is_void', 'is_voided'] as $k) {
            $f[$k] = $b($k);
            if ($f[$k] === null) {
                throw new UnexpectedValueException("Paymob callback missing/invalid boolean field: {$k}");
            }
        }
        // Refund/void events belong to refund handling, never to this mapping.
        if ($f['is_refund'] || $f['is_refunded'] || $f['is_void'] || $f['is_voided']) {
            throw new UnexpectedValueException('Refund/void event - route it to refund/void handling');
        }
        if ($f['error_occured']) return PaymentState::Failed;
        if ($f['pending'])       return PaymentState::Pending;  // kiosk/cash: NOT a failure
        if ($f['success']) {
            if ($f['is_capture'] || $f['is_standalone_payment']) return PaymentState::Paid;
            if ($f['is_auth'])                                   return PaymentState::Authorized;
            throw new UnexpectedValueException('Paymob success callback without a verified transaction type');
        }
        throw new UnexpectedValueException('Ambiguous Paymob callback: success=false, pending=false, error_occured=false');
    }
}

This exact code ran as 6 PHPUnit feature tests (44 assertions, all passing) inside a scratch Laravel 13.40 app on PHP 8.4.27 against MySQL 8.4.9, with an Eloquent model casting state to the enum and every transition persisted to a real payments table (the scratch database was dropped after the run): the full pending -> authorized -> paid flow round-trips through the database; an illegal paid -> pending transition throws LogicException and the stored row is unchanged; authorized -> voided works and voided refuses further transitions; the Tap mappings (CAPTURED/AUTHORIZED/INITIATED/VOID) and the Paymob boolean-field mappings normalize as shown; Paymob mappings are bounded by transaction type: purchase (is_standalone_payment) and is_capture successes map to paid, is_auth maps to authorized, and refund events, voided events, a missing pending field, an invalid boolean, an untyped success=true and the ambiguous all-false callback all throw and leave the database untouched (quarantined, never mapped to failed); an unknown Tap status throws too; and failed, voided and refunded are proven terminal. One vocabulary everywhere: checkout, dashboard, refunds, accounting export.

Webhooks: trust, but verify first#

Every provider here can notify you server-to-server - and every notification is an unauthenticated HTTP request until you prove otherwise:

  • Paymob includes an hmac query parameter on its callbacks; you recompute the HMAC from the received fields with your HMAC secret and compare before processing.
  • Tap sends webhooks (their IPN) to a URL you pass in the post parameter when creating the charge - and recommends relying on it, because a shopper may close the browser before ever returning to your site.
  • Fawry posts its Server-to-Server Notification V2 to the endpoint you register at merchant setup, carrying orderStatus (NEW, PAID, CANCELED, REFUNDED, EXPIRED, PARTIAL_REFUNDED, FAILED) and a messageSignature - a SHA-256 digest of the concatenated fields plus your secure key.
  • Tabby delivers webhooks for registered merchant_code + key pairs (self-service via POST /api/v1/webhooks), with lowercase statuses (authorized, closed, rejected, expired) and an optional static secret header you define at registration (a shared secret sent verbatim with each POST, not a per-payload HMAC signature - pair it with Tabby's published server IP allowlist); acknowledge with a 200 or Tabby retries.
  • Tamara posts webhook notifications (registered in its Partner Portal or by API) whose event_type maps to order statuses such as order_approved -> approved, order_captured -> fully_captured/partially_captured, and order_expired -> expired.
  • HyperPay issues merchant-specific integration docs for its gateway platform; treat the callback scheme as account-specific and verify it in the material issued to your account before mapping statuses.

Two rules cover all of them:

  1. Never change money state on an unverified webhook. Verify the signature/HMAC, and for anything that moves money, call the provider's status API to confirm the final state.
  2. Webhooks are at-least-once. Deduplicate on the provider's own event or operation ID where one exists (Tabby sends capture/refund objects with their own IDs; Tamara sends capture/refund IDs in the event data; Paymob and Tap send transaction IDs). There is no safe universal fingerprint - a composite like transaction + status + amount collides the day a customer gets two partial refunds of the same amount. Where no operation ID exists, fall back to durable reconciliation (re-query the provider's status API before applying) or a narrowly scoped fingerprint with the collision caveat documented - the payment-webhook article in this series goes deep on this.

Fallback and routing without shooting yourself#

Multiple gateways buy resilience and leverage - if you route deliberately. The dangerous mistake is treating every technical error as a safe retry: a timeout or 5xx means the outcome is unknown, not failed. The provider may have accepted the charge a millisecond before its response died, and your idempotency key means nothing to a different gateway - rerouting can charge the customer twice.

So split errors by what you can prove:

  • Provably not submitted (client-side validation rejection, connection refused before the request body left, the provider's explicit "transaction not created" response): safe to route to the next gateway under your written retry policy.
  • Ambiguous (timeout, 5xx, connection reset mid-response): mark the attempt pending/unknown, then query the original gateway's status API or wait for its webhook to reconcile. Only a confirmed not-processed result releases the order for another attempt - automatic or manual.
  • Declined: never reroute. A declined card retried on three gateways is three declines and a flagged account, not a sale.

And two hygiene rules: route by fit (card vs wallet vs cash vs BNPL, currency, fees, settlement currency - not "primary/backup" habit), and log the normalized result of every attempt against one payment record, so support sees one timeline, not three dashboards.

Common mistakes#

Mistake Why it hurts Do instead
Provider status strings in business logic Adding a gateway = rewriting checkout and refunds Normalize in the adapter only
No transition guards A late webhook flips paid back to pending Enum state machine with explicit legal transitions
Trusting the browser redirect Shoppers close tabs; orders hang Treat server-to-server webhooks as the source of truth
Unverified webhooks Forged "payment success" requests Verify HMAC/signature, confirm via status API
default => Failed status mapping Provider's next status rename silently loses payments Map verified statuses only; throw + quarantine the unknown
Retrying after timeout/5xx on another gateway The first attempt may have succeeded - double charge Mark unknown, reconcile with the original gateway first
Retrying declines on other gateways Extra fees, risk flags, angry customers Never reroute declines
One payment table per gateway Reporting and reconciliation become joins across silos One payments table, one state machine

Checklist before your second gateway goes live#

  • PaymentGateway interface + adapter per provider, nothing provider-specific outside adapters
  • One PaymentState enum with guarded transitions, persisted on every change
  • Status mapping tables reviewed against each provider's current docs
  • Webhook endpoint: signature verified, event IDs deduplicated, status confirmed server-to-server
  • Fallback policy written down: only provably-not-submitted failures reroute; ambiguous outcomes reconcile with the original gateway first; declines never reroute
  • Unknown or unrecognized provider payloads throw and land in a quarantine queue - never mapped to failed
  • Test suite covers illegal transitions and each provider's mapping

Key takeaways#

  • Adapters isolate dialect; the state machine is the language your system speaks.
  • Guard transitions in code - a late or forged webhook must not move a payment backwards.
  • Webhooks, not browser redirects, are the source of truth. Verify them before trusting them.
  • Unknown is not failed - in status mappings and in failover. Quarantine what you can't verify, reconcile what you can't prove, and never reroute a decline.

References#


Integrating several payment gateways and want the architecture reviewed before it calcifies? Let's talk.