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).
Paymob (Egypt)
Section titled “Paymob (Egypt)”Three sequential HTTP calls through an injected Guzzle Client, then a server-to-server webhook.
Init flow
Section titled “Init flow”- Authenticate —
getAuthenticationToken()(PaymobService.php:24-45) resolves the per-destination API key config keypayment.paymob.<slug>_api_key(:245-252), reads it viaconfig(), and POSTs it topayment.paymob.auth_url. Returnsmerchant_id+token(:41-44). - Register order —
registerOrder()(:47-76) POSTspayment.paymob.order_urlwithamount_cents = intval($amount * 100)(:56) andmerchant_order_id = "{id}__{name}__{now()}"(:58). Returns the Paymob order id (:75). - Get payment key —
getPaymentKey()(:78-142) creates theTransactionrow first viaStoreTransactionwithpayment_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 idpayment.paymob.<slug>_<egp|usd>_integration_id(:96-98,:226-234), and POSTspayment.paymob.payment_key_urlto get the paymenttoken(: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).
Callback / webhook
Section titled “Callback / webhook”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.
Signature validation
Section titled “Signature validation”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).
Thawani (Oman)
Section titled “Thawani (Oman)”A single HTTP call that creates a hosted checkout session.
Init flow
Section titled “Init flow”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).
Callback / webhook
Section titled “Callback / webhook”One public route (routes/api.php:35):
GET thawani/callback/{status} → CallbackController@thawaniCallbackController::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).
Signature validation
Section titled “Signature validation”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 (Montenegro)
Section titled “WSPG (Montenegro)”WSPG (WSPay) is a browser-POST gateway — the backend makes no outbound HTTP call; it signs a payload the browser submits.
Init flow
Section titled “Init flow”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).
Callback / webhook
Section titled “Callback / webhook”One public route (routes/api.php:37):
GET wspg/callback/{status} → CallbackController@wspgCallbackController::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.
Signature validation
Section titled “Signature validation”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.
Which gateway per country
Section titled “Which gateway per country”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 |
Config / env
Section titled “Config / env”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.
Known issues / security
Section titled “Known issues / security”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.
