# Merchant API security and operating limits This describes implemented controls, not an assertion of compliance certification. ## Trust boundaries Uganda's public Dotpay checkout is bound to a reference-specific HMAC capability with a separate namespace from return tokens and a 24-hour intent expiry. Capabilities permit only that stored attempt; they expose no merchant API key. Pages send no referrer, disable caching and framing, and the charge POST requires same-origin JSON. The endpoint ignores client amount, currency, email, merchant ID and callback overrides. MTN/AIRTEL selection and normalized Uganda phone are validated server-side. An atomic REQUIRES_ACTION-to-PROCESSING claim prevents concurrent debits. Existing provider transactions are checked before initiation; uncertain outcomes remain PROCESSING. A definitive provider rejection can be corrected after a cooldown, while authoritative settlement/refund state prevents resubmission. Provider initiation never grants access. Verified settlement also updates the corresponding mobile-money record. Clients use HTTPS to their own backend. The merchant backend holds a Dotpay API key and calls Dotpay over HTTPS. Dotpay holds provider credentials and calls MTN/Airtel. Wallet approval happens through the mobile money provider. No provider or merchant secret belongs in browser bundles or a public environment variable. API authentication uses SHA-256 key hashes and checks key status, expiry, application mode and merchant/app status. The integration-key generator uses 32 bytes of cryptographic randomness; signup's default key uses 24 bytes. Dashboard retrieval uses AES-256-GCM encrypted copies with a server-held 64-hex-character encryption secret. Preserve that secret during deployment; rotating it without re-encrypting stored copies breaks key retrieval. Rotation revokes the old merchant key; store replacements in the merchant server's secret manager. The authenticated Keys page lists only safe key metadata. Retrieving a key checks integration role, merchant ownership, application mode and key status/expiry, then records an API_KEY_VIEWED audit entry. Key-list and secret responses use Cache-Control: no-store; secrets are masked by default in the UI and are not saved in browser local/session storage. The portal's user-controlled sandbox form may hold that user's TEST key temporarily in memory. This exception is for testing inside the authenticated portal, not for embedding merchant credentials in a storefront bundle. Production integrations keep LIVE keys on their backend. Status lookups enforce application ownership before provider access. Public link lookups scope status to their exact link and recipient. Session mutations check request origin and active database users. Integration management checks role and merchant ownership. Merchant activation and account controls require SUPER_ADMIN. Sessions do not replace database access checks. There is no public role escalation flow. ## Payment integrity Primary LIVE checkout uses Flutterwave Standard with server-held live credentials. Hosted return addresses must match the application's registered `allowedOrigins`. Signed callback state binds a return to the stored attempt; query status never authorizes settlement. The optional v3 webhook checks `verif-hash` with a constant-time comparison. Both callback and polling verify the original reference through Flutterwave, matching reference, amount and currency. Existing webhooks on another domain do not prevent this verification. Atomic claims prevent duplicate settlement and preserve refunds; only a small verified status summary is stored rather than raw card/customer payloads. New Flutterwave return URLs carry the reference and HMAC state in path segments, so authorization pages replacing query parameters cannot discard them. Older `verify?resp=...` and `tx_ref`/`transaction_id` returns are supported only after authenticated transaction-ID verification matches the stored reference, amount and currency. Browser response status, amount, fee, identity and redirect fields have no authority and are not persisted. Invalid signed state cannot fall back to the unsigned format. Redirects use only stored merchant destinations and send `no-referrer` and `no-store` headers. TomDeriver normal checkout now uses LIVE mode and configured UGX/USD prices. Its return validates an order-bound random token hash and independently verifies the original owner's order. A missing external-browser account cookie does not change ownership. Web/native clients refresh pending orders automatically. The earlier owner-authorized TEST trial described below remains historical; new LIVE purchases do not receive sandbox grants. The app's immutable TEST/LIVE mode determines provider configuration. LIVE never falls back to sandbox hosts or credentials. Required provider settings are validated before creating a payment. Idempotency binds application and request ID to a hash of financial fields. A transaction creates intent, attempt, ledger and audit together, and atomically reserves links. Provider timeouts stay unresolved rather than automatically creating another debit. MTN success requires matching external ID, amount, currency and payer. Airtel final state requires the original transaction ID. Finalization atomically updates attempt, intent, ledger, phone record, link and audit. Terminal and refunded settlements are protected against stale responses. Airtel uses OAuth and signed Collections v2 requests with fresh AES keys and IVs encrypted using the provider RSA public key; status enquiry uses its authenticated v1 endpoint. Airtel's first enquiry is delayed 180 seconds. Merchant applications must independently bind paid=true, reference, the server-stored environment, amount, currency and stored order reference before fulfilment. Real-money orders require LIVE. TomDeriver's owner explicitly enabled an access-granting sandbox trial with `DOTPAY_CHECKOUT_MODE=TEST` and `DOTPAY_TEST_GRANT_ACCESS=true`. This permits any active signed-in customer to obtain real catalogue/service access through a simulated success without paying. The UI labels this trial; each server-created order pins TEST mode and an explicit access authorization, and settlement binds EUR 1 plus the stored order reference. Payment/subscription received amounts are zero, with TEST and quoted-price metadata retained. An existing LIVE order cannot be fulfilled by a TEST result. Failed, unrelated and mismatched results grant nothing; fulfilment is transactional and idempotent. Admin diagnostics remain separate and grant no access. Changing future checkout to LIVE does not rewrite or revoke access already granted during the trial. ## Scope and limits - `allowedOrigins` is enforced for hosted checkout return URLs, rather than merchant API browser authentication. `allowedIps` is not an API network restriction. API keys are backend credentials. - Merchant outgoing webhook delivery is not implemented. Incoming callbacks cannot mark payments paid. Use authenticated status enquiries and a scheduled reconciliation worker. - Audit records are database records, not an immutable external audit archive. Restrict database/operator access, back up records and export to controlled retention storage when required. - Multi-merchant provider settlement, payouts, refunds and compliance approval need distinct provider arrangements. A ledger net amount does not prove merchant bank/wallet payout. - Edge rate limiting and provider-specific operational monitoring must be configured at the gateway; this code does not claim a distributed API quota or DDoS protection mechanism. - No MFA or verified-email onboarding is implemented. Protect administrator accounts through the available hosting/identity controls and use a unique strong password. - Airtel approval, usable test wallets and provider IP allowlisting remain external prerequisites. MTN sandbox readiness does not prove MTN Uganda live approval. ## Deployment and incident handling Use production's existing database, authentication secret and key encryption secret. Keep test and live variables distinct. Exclude `.env*`, `.local-test`, credentials and backups from Git and deployment artifacts. Create a private database backup before schema/provider migration. Preserve historical transaction amounts/statuses; archive retired providers rather than deleting records. Store deployment IDs and before/after counts for rollback review. The production `User_phoneNumber_key` is a partial unique index on string phone numbers. Multiple absent/null optional phones are allowed, while actual phone numbers remain unique because sign-in accepts a normalized phone. Preserve this filter when applying Prisma index changes; do not replace it with an ordinary unique index that allows only one null. Payment-link token uniqueness and merchant/date indexes are applied explicitly. Disabled legacy remittance/disbursement routes return 410 after normal API authentication and never accept caller-provided settlement callbacks. Monitor failed authentication, persistent PROCESSING attempts, provider refusal/timeout rates and reconciliation age. On compromise, revoke affected merchant keys, restrict the account and investigate audit/payment records before enabling it again. Rotate provider credentials in coordination with the provider and update the secret store. Never log secrets or full authentication headers.