Skip to content

Payment gateways (Paymob, Thawani, WSPG)

This is the per-gateway integration reference. For the end-to-end money-in pipeline (ledger row, public callback, the “paid but not reserved” signal) read The online payment pipeline — this page complements it by documenting each gateway on its own.

One gateway per country. All three run through service classes under app/Services/ (PaymobService.php, ThawaniService.php, WSPGService.php), each extending the abstract PaymentService (app/Services/PaymentService.php). Country selection happens in PaymentSale::handle() (app/Actions/Sale/PaymentSale.php:16-49) off the unit’s country slug: CountryEnums::EGYPT builds PaymobService (:23-33), CountryEnums::OMAN builds ThawaniService (:34-38), CountryEnums::MONTENEGRO builds WSPGService (:39-43).

Three sequential HTTP calls through an injected Guzzle Client, then a server-to-server webhook.

  1. AuthenticategetAuthenticationToken() (PaymobService.php:24-45) resolves the per-destination API key config key payment.paymob.<slug>_api_key (:245-252), reads it via config(), and POSTs it to payment.paymob.auth_url. Returns merchant_id + token (:41-44).
  2. Register orderregisterOrder() (:47-76) POSTs payment.paymob.order_url with amount_cents = intval($amount * 100) (:56) and merchant_order_id = "{id}__{name}__{now()}" (:58). Returns the Paymob order id (:75).
  3. Get payment keygetPaymentKey() (:78-142) creates the Transaction row first via StoreTransaction with payment_reference_id = (string) $payMobOrder, type = paymob, status = pending (:84-92), links it with $sale->update(['transaction_id' => …]) (:94), resolves the per-destination-and-currency integration id payment.paymob.<slug>_<egp|usd>_integration_id (:96-98, :226-234), and POSTs payment.paymob.payment_key_url to get the payment token (:141).

The payment_reference_id for Paymob is the Paymob order id (an integer, stored stringified). EGP and USD resolve to different Paymob integrations for the same destination (:226-234).

Paymob is split across two public routes (routes/api.php:32-33):

POST paymob/callback → CallbackController@paymob (server-to-server webhook)
GET paymob/callback → CallbackController@paymobResponseCallback (browser redirect)

The webhook (CallbackController.php:16-21) calls PaymobService::handleTransactionCallback() (PaymobService.php:144-164), which decides success on $data['obj']['success'] == 'true' (:153) and dispatches to handleSuccessTransactionCallback / handleFailTransactionCallback keyed by $data['obj']['order']['id'] (:155, :160). This webhook is what actually writes the status.

The browser redirect (paymobResponseCallback, :23-28) calls handlePaymobResponseCallback() (PaymentService.php:100-116), which sleep(10)s (:102), looks the transaction up by $data['order'] (:103), reads its already-persisted status, and Redirect::away()s. If the webhook is late the browser reads a stale pending and the match returns null (:114) — a blank redirect even though the charge may still settle.

Were the guard enabled, checkTransactionCallbackHMAC() (:166-211) would concatenate the ~20 Paymob fields in documented lexical order (:186-206), compute hash_hmac('SHA512', $hmacValues, $hmacSecret) (:208) using the per-destination secret payment.paymob.<slug>_hmac_secret (:236-243), and compare with return $hmac == $data['hmac']; (:210) — a non-constant-time == compare (see Known issues).

A single HTTP call that creates a hosted checkout session.

createSession() (ThawaniService.php:21-73) generates $uniqueUuid = (string) Str::uuid() (:23), resolves the API secret as $sale->unit->project->payment_api_secret_key ?? config('payment.thawani.secret_key') (:25 — a per-project DB secret with a config fallback), then POSTs payment.thawani.create_session (:27) with header thawani-api-key (:48) and unit_amount = intval($amount * 1000) (baisa, :35). The success/cancel URLs carry ?payment_reference_id=<uuid> (:38-39). It then creates the Transaction with type = thawani, status = pending, currency = OMR (hard-coded, :65) and payment_reference_id = $uniqueUuid (:59-68), links it (:70) and returns the session_id (:72).

The payment_reference_id for Thawani is the app-generated UUID (also echoed in the return URL).

One public route (routes/api.php:35):

GET thawani/callback/{status} → CallbackController@thawani

CallbackController::thawani() (CallbackController.php:30-35) forwards to handleTransactionCallbackStatus() (:44-65), which switches on the URL {status} path segment: success → handleSuccessTransactionCallback, cancel → handleFailTransactionCallback(…, true), fail → handleFailTransactionCallback. The transaction is matched by the echoed payment_reference_id query param inside handleSuccessTransactionCallback (PaymentService.php:40).

None on the inbound callback. There is no Thawani service method that recomputes a gateway signature or verifies a webhook secret — success is decided purely by the {status} path segment plus the echoed payment_reference_id. Treat that as the security posture, not settlement proof.

WSPG (WSPay) is a browser-POST gateway — the backend makes no outbound HTTP call; it signs a payload the browser submits.

getSignatureKey() (WSPGService.php:13-48) makes no HTTP call. It generates $uniqueUuid = (string) Str::uuid() (:15), creates the Transaction with type = wspg, status = pending, payment_reference_id = $uniqueUuid and currency = $sale->unit->currency (:17-26), links it (:28), and returns a signed array (:39-47): shop_id, amount, shopping_cart_id = uuid, signature, and success_url / cancel_url / fail_url — each carrying ?payment_reference_id=<uuid> (:37, :44-46). The SPA POSTs these as a hidden form to the WSPay payment page.

The payment_reference_id for WSPG is the app-generated UUID (also echoed in the return URLs).

One public route (routes/api.php:37):

GET wspg/callback/{status} → CallbackController@wspg

CallbackController::wspg() (CallbackController.php:37-42) forwards to the same handleTransactionCallbackStatus() (:44-65) used by Thawani — identical {status}-driven switch, matched by the echoed payment_reference_id.

The only signature WSPG computes is the outbound one in getSignatureKey(): hash('sha512', $dataToHash) (:43) over shop_id . secret . uuid . secret . intval($amount * 100) . secret (:30-35), using payment.wspg.shop_id and payment.wspg.secret_key. There is no inbound signature verification on wspg/callback — same trust model as Thawani.

Selection is by the unit’s country slug in PaymentSale::handle() (app/Actions/Sale/PaymentSale.php:22-45). Only UnitSale fans out to all three; UnitAddonSale and EOIRequest are Egypt / Paymob only (see the pipeline doc).

Country Gateway Service class Transaction type Outbound HTTP at init payment_reference_id is
Egypt Paymob PaymobService.php paymob Yes (3 calls) Paymob order id (int, stringified)
Oman Thawani ThawaniService.php thawani Yes (1 call) app-generated UUID
Montenegro WSPG WSPGService.php wspg No (browser POST) app-generated UUID

Gateway config lives in config/payment.php (not config/services.php), keyed by provider. All values come from env(); the .env.example names are listed below (values never committed). Never print a secret value.

Paymob (config/payment.php:4-20) — per-destination credentials for el-gouna, makadi-heights, o-west:

  • ELGOUNA_PAYMOB_API_KEY, MAKADI_PAYMOB_API_KEY, OWEST_PAYMOB_API_KEY — per-destination API keys.
  • ELGOUNA_PAYMOB_HMAC_SECRET, MAKADI_PAYMOB_HMAC_SECRET, OWEST_PAYMOB_HMAC_SECRET — HMAC secrets (consumed only by the currently-disabled HMAC check).
  • ELGOUNA_EGP_PAYMOB_INTEGRATION_ID, ELGOUNA_USD_PAYMOB_INTEGRATION_ID, MAKADI_EGP_PAYMOB_INTEGRATION_ID, MAKADI_USD_PAYMOB_INTEGRATION_ID, OWEST_EGP_PAYMOB_INTEGRATION_ID, OWEST_USD_PAYMOB_INTEGRATION_ID — one integration per destination-and-currency.
  • PAYMOB_AUTH_URL, PAYMOB_ORDER_URL, PAYMOB_PAYMENT_KEY_URL — the three endpoints.

Thawani (config/payment.php:21-28): THAWANI_API_URL, THAWANI_CREATE_SESSION_URL, THAWANI_PUBLIC_KEY, THAWANI_SECRET_KEY, THAWANI_SUCCESS_URL, THAWANI_CANCEL_URL. The secret can be overridden per project via the payment_api_secret_key column (ThawaniService.php:25).

WSPG (config/payment.php:29-36): WSPG_API_URL, WSPG_SHOP_ID, WSPG_SECRET_KEY, WSPG_SUCCESS_URL, WSPG_CANCEL_URL, WSPG_FAIL_URL.

The three findings flagged in backend/CLAUDE.md section 10, verified against source:

1. Paymob HMAC compare uses ==, not hash_equals — CONFIRMED (and worse). checkTransactionCallbackHMAC() ends with return $hmac == $data['hmac']; (PaymobService.php:210) — a non-constant-time string compare that is timing-attack vulnerable; it should use hash_equals. Worse: that whole method is commented out at the call site (:146-152), so it never runs. The Paymob webhook currently accepts any body and trusts $data['obj']['success'] == 'true' (:153) with no signature verification at all.

2. “Hardcoded API keys in some payment code paths” — NOT reproduced in the gateway code. The audit note says to search app/Services/Payment/; the real path is the flat app/Services/. In all three gateway classes every credential resolves through config() / env() (config/payment.php:4-36) — no literal keys. Two related real risks remain: the resolved Paymob integration id is written to the debug log (PaymobService.php:100), and the Thawani secret can come from a DB column (payment_api_secret_key, ThawaniService.php:25). Re-open only with a concrete file:line if a hardcoded value is found elsewhere.

3. Weak webhook timestamp validation / replay — CONFIRMED (by absence). There is no timestamp, nonce, or idempotency check anywhere in the callback path. Paymob’s HMAC is disabled (:146-152); Thawani/WSPG decide success from the URL {status} segment plus the echoed payment_reference_id (CallbackController.php:44-65) with no gateway signature. And handleSuccessTransactionCallback() (PaymentService.php:36-73) does not check whether the row is already success before re-processing — so a re-delivered/replayed success callback re-submits to the CRM and re-fires notifications for the same payment_reference_id.

Bonus (verified while reading): the init-flow status guards use a precedence-bug pattern — if (! $response->getStatusCode() == 200) in PaymobService.php:69 and ThawaniService.php:52. ! binds tighter than ==, so this evaluates (! $status) == 200 and is never true — the intended “failed to register / create session” exception can never fire from these guards.