modules
Payments module
Connect Paystack, MTN MoMo, or Hubtel so your customers can pay you and so you can buy messaging credits.
Updated 2026-04-19
Two different payment flows
Opterflow handles two separate payment scenarios:
- Your customers pay you — someone pays your invoice through Paystack checkout, MTN MoMo prompt, or Hubtel. Money goes to your settlement account.
- You pay Opterflow for messaging credits — you top up SMS/WhatsApp credits via Paystack. Money goes to Opterflow.
Both live under the Payments module but use different plumbing.
Provider capabilities in Ghana
| Provider | Card | MoMo (MTN) | MoMo (Vodafone/AirtelTigo) | Bank | Fee | Setup |
|---|---|---|---|---|---|---|
| Paystack | ✓ | ✓ | ✓ | ✓ | ~1.95% + GHS 0.50 | Easy — we create a subaccount under Opterflow |
| MTN MoMo direct | ✗ | ✓ | ✗ | ✗ | ~1% | Medium — you bring your own API creds |
| Hubtel | ✓ | ✓ | ✓ | ✓ | ~1.5–2.5% | Medium — you bring merchant account # + client ID/secret |
Recommendation: connect Paystack first. Broadest coverage, easiest onboarding. Add MTN or Hubtel later if you want lower fees on MoMo-heavy flows.
Connecting Paystack
- Go to Settings → Payments
- Click Connect Paystack
- Fill in:
- Business name (prefilled from your org)
- Settlement bank — dropdown of Ghana banks
- Settlement account number — where Paystack will deposit your money (auto-validates via Paystack; shows account holder name on blur)
- Primary contact — your name, email, phone
- Submit. The status card shows pending while Paystack verifies.
- Once Paystack confirms the account (usually minutes), their webhook flips your status to connected.
What Opterflow stores: just the Paystack subaccount code. No secret keys — Paystack routes payments through our master account and splits the subaccount's cut at settlement time.
Connecting MTN MoMo direct
Harder than Paystack. You need to onboard with MTN's developer portal first, then paste creds here.
- Go to MTN's MoMo developer portal and provision Collections API credentials. You'll get:
- API User (UUID)
- API Key (secret)
- Subscription Key (Ocp-Apim-Subscription-Key)
- In Opterflow: Settings → Payments → Connect MTN MoMo
- Paste all three + pick environment (sandbox / mtnghana)
- Opterflow encrypts them with AES-256-GCM before writing to the DB (requires
PAYMENTS_KEK_BASE64env var in Vercel) - Validates by minting a bearer token. Success → status=connected. Failure → 400 with the reason.
Connecting Hubtel
- Open a Hubtel merchant account at hubtel.com
- Grab your Merchant Account Number, Client ID, and Client Secret
- In Opterflow: Settings → Payments → Connect Hubtel
- Paste all three. Opterflow encrypts and validates.
Requesting payment on an invoice
Invoices → click any invoice → Request pay button.
- If you have one provider connected, it uses that.
- If you have multiple, you're prompted to pick.
Paystack opens a checkout URL in a new tab. MTN MoMo sends a push prompt to the customer's phone (no URL). Hubtel opens a checkout URL.
Idempotent: clicking twice reuses the same PaymentIntent, no duplicate charges.
How we know a payment succeeded
Each provider fires a webhook. Opterflow verifies the signature (or shared secret for Hubtel/Unipile), deduplicates against PaymentWebhookEvent, and on success:
- Marks the PaymentIntent
succeeded - Flips the Invoice to
paidwithpaidAt = now - Creates a Receipt row with the reference
- Emits a
ContactSignal(type='purchase')— this is what lets Campaign v2 branches fire on "did they pay"
Reconciliation cron
MTN MoMo callbacks are notoriously flaky. There's a cron at /api/cron/payments-reconcile that runs every 5 minutes:
- Finds intents stuck in
processingfor > 5 minutes - Calls
getStatus()on the provider - If SUCCESSFUL or FAILED, applies the same update path as the webhook
Paystack doesn't need this (their webhook is reliable). Hubtel gets reconciled too.
Messaging top-up (you paying Opterflow)
Separate flow. Open Settings → Messaging → Top Up Credits.
- Admin or manager can submit
- Pick channel (SMS / WhatsApp) + amount
- Two payment paths:
- "Pay with Paystack" (primary, blue CTA) — instant. Paystack webhook auto-approves the top-up. Credits applied within seconds.
- "Bank transfer / cash" (secondary) — creates a
topup_pendingtransaction. Super admin manually approves from/admin/messaging-topupsafter confirming payment off-platform.
Both paths work. Paystack is faster and doesn't need human approval.
Viewing transaction history
/payments shows every PaymentIntent for your org, filterable by provider, status, purpose, date range. Click a row → detail drawer with full webhook event timeline (every callback Paystack/MTN/Hubtel sent us).
Security
- Per-tenant encrypted credentials (MTN, Hubtel) — AES-256-GCM with
PAYMENTS_KEK_BASE64. DB dump leaks ciphertext only. - Webhook signature verification on every callback. Wrong signature = 401.
- Amount tampering defence — if webhook amount ≠ intent amount, intent marked failed, invoice stays unpaid, flagged for super admin.
- Disconnect clears ciphertext — revoking a provider zeros the encrypted columns.
Common mistakes
- Paystack test mode keys in prod — Paystack sandbox
sk_test_keys can't charge real cards. Check your env vars. - Cloudflare proxy on webhook URLs — if webhooks go through Cloudflare's orange-cloud, raw body bytes may differ, breaking signature verification. Turn proxy OFF on the subdomain that serves
/api/webhooks/*. - Missing callback URL on MTN MoMo — without
MTN_MOMO_CALLBACK_HOST, MTN can't notify us. Reconciliation cron will catch it, but with 5-min lag. - Fees passed to customer. Default is tenant absorbs. Per-org
passFeesToCustomertoggle exists but UI surface is coming in v1.1.