Paystack, Hubtel, MTN MoMo and payment links: keys, webhooks and testing.

SimpliFi — Payments (module M3)

How SimpliFi takes money for WiFi packages, PPPoE bills, platform invoices and SMS credits, how each Ghanaian gateway is set up, and how the money (and Henapp's share) flows. Contract details for other modules live in docs/ARCHITECTURE.md §7.2, §9 and §18.


1. Money flow

Customer ──pays──▶ gateway ──settles──▶ ISP (own gateway)            SimpliFi invoices its share later
Customer ──pays──▶ SimpliFi Paystack ──split──▶ ISP subaccount        SimpliFi's share kept at source
Own gateways (default) SimpliFi Collect (collection_mode = platform_split)
Who owns the gateway account The ISP (Paystack / Hubtel / MTN MoMo / link / cash) Henapp (platform Paystack gateway)
Where the money settles ISP's account, immediately ISP's MoMo wallet / bank via a Paystack subaccount
Henapp's share Invoiced per billing period (M8) Deducted from every payment (transaction_charge)
payments.platform_fee amount × revenue share % (recorded, invoiced later) same amount, fee_collected_at_source = true (excluded from invoices)
  • Revenue share (default 10 %): platform_fee = amount × tenants.revenue_share_percent / 100 on successful package and PPPoE invoice payments. Fixed billing: platform_fee = 0; the fixed fee is invoiced per cycle. Platform invoices and SMS credit purchases never carry a fee.
  • Gateway charges (Settings → Payments → "Gateway charges", setting payments.fee_bearer): isp (default) — customers pay the exact price, the provider's fee comes out of the ISP's money; customer — the per-method "Fee added when customers pay the charges (%)" is added on top at checkout (stored in payments.meta.surcharge; the package price stays in payments.amount).
  • With SimpliFi Collect, Paystack's own fees come out of the ISP's share (bearer = subaccount).

2. Payment lifecycle

  1. A module creates the payment: PaymentService::create([...]) (status pending, reference SF-<ULID>).
  2. The payer picks a method on the hosted page (tenant.pay.show, /pay/{uuid}) — or the module calls initiate() itself. Mobile-money prompts set the status to processing.
  3. Confirmation always comes from the gateway, server-side: webhook ⇒ verify API, the pay page polling api.payments.status (verify at most every 10 s), or the payments:reconcile job (every minute, backoff 15 s → 15 min).
  4. PaymentService::markSuccessful() — the only success path — locks the row, checks amount paid >= amount (else failed / amount_mismatch), sets paid_at, verified_at, channel, network, the platform fee, and dispatches PaymentCompleted once, after commit.
  5. Open payments older than 2 h get one last check and are failed with timeout. Money that arrives later (webhook / "Verify now") is still honoured.

Payment-link payments without a signed webhook: the payer submits the provider's transaction ID ⇒ processing ⇒ staff open Payments and Approve (after checking their statement) or Reject. A transaction ID can only be used once per payment method.

3. Webhook URLs

Each saved gateway shows its own URL (Settings → Payments, or Super admin → Payment gateways), e.g.

https://<APP_URL host>/webhooks/payments/paystack/12
https://<APP_URL host>/webhooks/payments/hubtel/13
https://<APP_URL host>/webhooks/payments/mtn_momo/14
https://<APP_URL host>/webhooks/payments/payment_link/15

Hosted checkouts return the payer to /webhooks/payments/return/{payment uuid} (Paystack callback_url, Hubtel returnUrl / cancellationUrl), which verifies and forwards to the pay page. Every inbound call is stored in webhook_logs (without Authorization / cookie headers).

4. Gateway setup

  • Keys: Paystack Dashboard → Settings → API Keys & Webhooks. Paste the secret key (sk_test_… while testing, sk_live_… live); the public key is optional.
  • Webhook: paste the gateway's webhook URL in the same dashboard page. SimpliFi checks the x-paystack-signature header (HMAC-SHA512 of the raw body with the secret key) and only acts on charge.success, then re-verifies with GET /transaction/verify/{reference}. Docs: https://paystack.com/docs/payments/webhooks/
  • How customers pay:
  • Paystack requires an e-mail; payers without one get <phone>@<PAYMENTS_FALLBACK_EMAIL_DOMAIN or APP host>.
  • Test mode: use your sk_test_ key and Paystack's test numbers / cards from https://paystack.com/docs/payments/test-payments/ (Ghana MoMo test numbers are listed there, e.g. 0551234987 for MTN). "Test connection" calls GET /balance.

Hubtel

  • Credentials (Hubtel merchant dashboard → API keys): API ID (client ID), API key (client secret) and the merchant account number / POS Sales ID. All three must belong to the same account.
  • Modes: Direct Receive Money (direct, default) — POST https://rmp.hubtel.com/merchantaccount/merchants/{POS}/receive/mobilemoney with channel mtn-gh / vodafone-gh / tigo-gh; Online Checkout (checkout) — POST https://payproxyapi.hubtel.com/items/initiate (hosted page, MoMo + cards).
  • Confirmation: Hubtel callbacks are not signed, so a callback only triggers the status check GET https://api-txnstatus.hubtel.com/transactions/{POS}/status?clientReference=… (Paid ⇒ success). Ask Hubtel to enable Direct Receive Money and to whitelist your server's public IP for the status API — without it "Test connection" reports HTTP 403 and payments only confirm by callback + status.
  • Docs (sign-in required): https://developers.hubtel.com (Receive Money, Transaction Status Check), https://dev-docs.hubtel.com/frontend/tools-and-sdks/checkout/api-reference.html (Online Checkout). The three URLs are configurable (HUBTEL_RECEIVE_URL, HUBTEL_STATUS_URL, HUBTEL_CHECKOUT_URL) in case Hubtel changes them. Hubtel has no public sandbox: test with small live amounts.

MTN MoMo (Collections API)

  • Sandbox: register at https://momodeveloper.mtn.com, subscribe to Collections, create an API user + key (POST /v1_0/apiuser, POST /v1_0/apiuser/{id}/apikey) and paste the subscription key, API user (UUID) and API key. Test mode always uses https://sandbox.momodeveloper.mtn.com, target environment sandbox and currency EUR (sandbox limitation). Sandbox test MSISDNs such as 46733123450 (failed) / 46733123451 (rejected) / 46733123452 (timeout) simulate outcomes; other numbers succeed.
  • Live: MTN Ghana issues production credentials and the target environment (default mtnghana); live mode calls https://proxy.momoapi.mtn.com in GHS and only accepts MTN numbers.
  • Callbacks: tick "Send our callback URL" only when the API user's callback host is this server (MTN rejects other hosts). Payments are confirmed by polling GET /collection/v1_0/requesttopay/{id} anyway.
  • Docs: https://momodeveloper.mtn.com/api-documentation/api-description
  • Payment link URL with placeholders {amount} (e.g. 5.00), {reference} (SF-…), {phone} (024…), {email}; must start with https:// (or http://).
  • Without a webhook the payer pays, comes back and submits the transaction ID; staff approve it.
  • With a webhook: set the signing secret, the signature header (default X-Signature, value = hex HMAC-SHA256 of the raw body, optionally prefixed sha256=), and the JSON paths of our reference, the status and the amount (data.reference, …). Values listed in "Status values meaning paid" complete the payment automatically (the signed webhook is the confirmation); the amount must cover the payment.

Cash / manual

Instructions only. Staff record cash sales through the voucher / sales screens, which call PaymentService::recordCash() (immediately successful, user_id = the staff member).

5. SimpliFi Collect (platform split)

  1. Super admin → Payment gateways → add Henapp's Paystack gateway (live secret key) and paste its webhook URL into Henapp's Paystack dashboard.
  2. The ISP owner opens Settings → Payments → Collection mode → chooses a MoMo wallet or bank (list from Paystack GET /bank?type=mobile_money|ghipss) and the account number. SimpliFi creates a Paystack subaccount (POST /subaccount, percentage_charge = revenue share %), stores tenants.paystack_subaccount_code and switches to platform_split.
  3. Package / PPPoE payments then use the platform Paystack gateway with subaccount, transaction_charge = platform fee in pesewas and bearer = subaccount. Docs: https://paystack.com/docs/payments/split-payments/

Only the owner (billing.manage) can change the collection mode or the settlement account. When a tenant's billing mode changes, update the subaccount (re-submit the form) so Paystack's default percentage matches; each payment also sends an explicit transaction_charge.

6. For other modules

$payments = app(\App\Services\Payments\PaymentService::class);

$payment = $payments->create([
    'gateway' => $gateway,            // optional: let the payer choose on the pay page
    'purpose' => Payment::PURPOSE_PACKAGE,
    'amount' => $package->price,
    'package_id' => $package->id,
    'customer_id' => $customer?->id,
    'phone' => $phone,
    'meta' => ['return_url' => route('portal.success'), 'mac' => $ctx->clientMac],
]);

return redirect()->away(\App\Services\Payments\PaymentUrls::pay($payment));

Listen for PaymentCompleted (check purpose, wrap work in tenancy()->runAs()); set fulfilled_at when you deliver. Poll GET /api/payments/{uuid}/status from your own pages if needed.

7. Operations

  • Scheduler: payments:reconcile every minute (verify due payments, expire stale ones).
  • Config / env: PAYMENTS_HTTP_TIMEOUT (15), PAYMENTS_PENDING_TIMEOUT (120 min), PAYMENTS_FALLBACK_EMAIL_DOMAIN, PAYSTACK_BASE_URL, HUBTEL_*_URL, MTN_MOMO_*_URL.
  • Captive portal walled garden: allow the APP host plus the gateway hosts (*.paystack.com, *.paystack.co, *.hubtel.com, *.mtn.com.gh) so payers can reach checkout pages before login.
  • Troubleshooting: Super admin → Payments → a payment shows its webhook log (signature valid / errors) and raw gateway responses; "Verify now" re-checks with the gateway (also for failed payments).

8. More gateways (M11 / M12)

Settings → Payments now opens with a searchable gateway picker ("Recommended for Ghana" first, then by region) and each gateway page shows its own setup guide, the webhook URL to copy and a Test credentials button that checks the keys before saving. Drag the configured methods to set their order; "Request a gateway" sends SimpliFi a support ticket for anything missing.

Gateway Guide Notes
Flutterwave docs/gateways/flutterwave.md Standard checkout or Ghana MoMo charge; webhook verif-hash
pawaPay docs/gateways/pawapay.md MoMo prompt in 21 countries (MTN / Telecel / AirtelTigo in Ghana)
expressPay docs/gateways/expresspay.md Ghana checkout; server IP must be whitelisted
theTeller (PaySwitch) docs/gateways/theteller.md Checkout or MoMo prompt
Moolre docs/gateways/moolre.md MoMo prompt (with SMS code on first use) or Web POS link
Squad by GTCO + GTBank Ghana link docs/gateways/squad.md Squad = NGN / USD; GTBank Ghana is a payment-link preset
Stripe docs/gateways/stripe.md Checkout Sessions for diaspora cards
DPO Pay docs/gateways/dpo.md Pan-African hosted page (XML API)
Korapay docs/gateways/korapay.md Checkout or MoMo (GH / KE)
Zeepay docs/gateways/zeepay.md Payment-link preset (no verifiable public production API)
M-Pesa (Safaricom Daraja) docs/gateways/mpesa_daraja.md Kenya STK Push (paybill or till); confirmed by STK query
Pesapal docs/gateways/pesapal.md Hosted checkout KE / UG / TZ / RW / MW / ZM / ZW; IPN registered automatically
AzamPay docs/gateways/azampay.md Tanzania USSD push; RSA-signed callback is the confirmation
ClickPesa docs/gateways/clickpesa.md Tanzania USSD push or checkout link; optional checksum; no sandbox
Relworx docs/gateways/relworx.md Uganda / Kenya / Tanzania prompt; Relworx-Signature webhooks; no sandbox
ioTec Pay docs/gateways/iotecpay.md Uganda prompt into an ioTec wallet; shared-secret callback header
JulyPay docs/gateways/julypay.md Uganda payment-link preset (API docs not public): transaction ID + approval
Fapshi docs/gateways/fapshi.md Cameroon MTN / Orange prompt or payment link; x-wh-secret webhooks
Payfast docs/gateways/payfast.md South Africa hosted page; ITN validated (signature, IP, server confirmation)
Paynow docs/gateways/paynow.md Zimbabwe web checkout or EcoCash / OneMoney Express; SHA512-hashed messages
Airtel Money docs/gateways/airtel_money.md Airtel Africa Open API USSD push in 13 markets; hashed callbacks

Payment-link family (M12): any driver extending PaymentLinkDriver (today payment_link and julypay) gets the "submit transaction ID ⇒ staff approve" flow (PaymentGateway::isLinkDriver()).

Walled garden: also allow the hosts of the gateways you enable (e.g. *.flutterwave.com, *.pawapay.io, *.expresspaygh.com, *.theteller.net, *.moolre.com, *.squadco.com, *.stripe.com, *.3gdirectpay.com, *.korapay.com). Hosted pages of M12 gateways: *.pesapal.com, *.clickpesa.com, *.fapshi.com, *.payfast.co.za, *.paynow.co.zw (prompt-only gateways — M-Pesa, AzamPay, Relworx, ioTec, Airtel Money — need nothing extra).

Ready to try it on your network?

Start your 14-day free trial today. Set up in minutes, no credit card, cancel any time.

Install SimpliFi App

One tap, no app store. Faster, full screen, works on weak signal.