Payment Workflow (Invoice-First, Shared Pay Page)¶
Last Updated: May 2026
Applies to: All services that collect payments (ordering, subscription, cafe-website, treasury-ui). Treasury-api is the single source for payment intents and gateway orchestration.
Overview¶
Payments follow an invoice-first flow:
- A service (ordering-backend, subscription-api, cafe-website backend, etc.) creates a payment intent (invoice) via treasury-api with payment details and
payment_method: "pending". - Treasury-api returns intent_id and invoice details (amount, currency, reference_id, reference_type).
- The service’s frontend redirects the user to the shared treasury-ui pay page with these details (and optional
intent_id,invoice_number,initiate_url, etc.) in the URL. - The pay page shows an invoice summary at the top, then lists active payment gateways (Paystack, M-Pesa, COD). The user picks one; the corresponding payment modal opens with the same payment context.
- When the user completes the flow (redirect to Paystack, STK push, or COD/manual confirmation), the intent is updated via webhooks or API; the user is sent to the callback page or redirect_url with a configurable button.
1. Create Invoice (Payment Intent) — Treasury API¶
Endpoint: POST /api/v1/{tenant}/payments/intents
Auth: Required (service backend calls with valid JWT).
Request (invoice-only; user will choose method on pay page):
{
"reference_id": "order-123",
"reference_type": "order",
"payment_method": "pending",
"currency": "KES",
"amount": 1500,
"source_service": "ordering",
"description": "Order #123"
}
- Use
payment_method: "pending"(or omit) to create the intent without initiating any gateway. No redirect or STK is sent yet. source_serviceis REQUIRED for revenue attribution. Each service must use its canonical identifier:ordering— ordering-backend (online delivery orders)pos— pos-api (point-of-sale transactions)subscriptions— subscriptions-api (plan billing)logistics— logistics-api / truload (delivery/weighing fees)cafe— cafe-website (direct cafe orders)isp— isp-billing (ISP payments)- Treasury-api uses
source_serviceto attribute revenue per service and generate per-service analytics. - Response:
intent_id,status,amount,currency,reference_id,reference_type. The service stores or passes these to the frontend.
Optional: If the service already knows the method (e.g. “paystack” or “mpesa”), it can send that and treasury-api will initiate the gateway immediately (existing behavior). For the shared pay page flow, use "pending".
2. Initiate Payment for an Existing Intent — Treasury API¶
After the user selects a gateway on the pay page, the calling service’s backend (or a proxy) calls:
Endpoint: POST /api/v1/{tenant}/payments/intents/{intentID}/initiate
Auth: Required.
Request:
{
"payment_method": "paystack",
"customer_email": "user@example.com"
}
- For M-Pesa:
payment_method: "mpesa",phone_number: "254712345678". Optionalgatewaypins the provider:"daraja"(the business's own paybill or till) or"payhero". Without it, treasury tries Daraja first, then PayHero. - For PayHero's other rails:
payment_methodis the rail (airtel_money,mtn_momo,payhero_momo,payhero_card,payhero_bank) withgateway: "payhero". - For COD:
payment_method: "cash"— intent is marked succeeded without gateway. - For manual / paid at till:
payment_method: "manual"or"till"— intent is marked succeeded without calling the gateway (user confirmed they paid at agent/till).
Response: Same shape as create intent — e.g. authorization_url (Paystack), checkout_request_id (M-Pesa), or status: "succeeded" for cash/manual.
3. Confirm Manual Payment (Paid at Till) — Treasury API¶
When the user clicks “I paid at till / agent” in the UI, the service backend can either:
- Call initiate with
payment_method: "manual"(see above), or - Call confirm-manual for the intent:
Endpoint: POST /api/v1/{tenant}/payments/intents/{intentID}/confirm-manual
Auth: Required.
Body: Empty.
Effect: Intent status set to succeeded; payment_method can be updated to "manual".
4. Shared Pay Page (Treasury UI)¶
URL: Public; e.g. https://books.codevertexafrica.com/pay
Route: /(public)/pay (no auth).
Query parameters (passed by the service frontend when redirecting):
| Parameter | Required | Description |
|---|---|---|
amount |
Yes* | Amount to pay (*or intent_id if amount from server) |
tenant |
Yes | Tenant ID (UUID) |
intent_id |
No | Treasury payment intent ID (invoice-first flow) |
invoice_number |
No | Display only (e.g. INV-xxx or reference_id) |
reference_id |
No | External reference (order id, subscription id) |
reference_type |
No | e.g. order, subscription |
currency |
No | Default KES |
description |
No | Shown in summary |
redirect_url |
No | Where to send user after payment (path or URL) |
button_text |
No | Label for post-payment button (e.g. “View my order”) |
initiate_url |
No | Service backend URL to POST to initiate/confirm |
gateways |
No | Comma-separated: paystack, payhero, mpesa, wallet, cod (default: all). Older links naming PayHero rails (airtel_money, payhero_offline, ...) open PayHero on those rails. |
Page behavior:
- Renders invoice summary at top (invoice number, amount, description).
- Renders gateway cards for the chosen
gateways, led by the official logos (Paystack, PayHero, M-Pesa); a logo that already says the name stands in for the title. With exactly one gateway in an embedded page, it opens straight away. - On gateway click, opens the respective payment modal with the same payment details. PayHero, like Paystack, has its own checkout: the rails it offers for the payment's currency (M-PESA, Airtel Money, MTN MoMo, card, bank) on the left and the chosen rail's form on the right. M-Pesa is the business's own Daraja paybill or till.
- Provider logos come from
@bengo-hub/shared-ui-lib(brand-logos) only.
initiate_url (recommended): The pay page and modals POST to this URL with body e.g. { intent_id, payment_method, customer_email?, phone_number? }. The service backend then calls treasury-api POST .../intents/{id}/initiate (or confirm-manual) and returns authorization_url, checkout_request_id, or redirect_url so the UI can redirect or show “Check your phone”.
5. Payment Modals (Flexible, QR & Manual)¶
All payment dialogs in treasury-ui support:
- Standard flow: Paystack → redirect to
authorization_url; M-Pesa → STK push, then optional “Check your phone” / redirect; COD → confirm and mark succeeded. - QR code: When an
authorization_urlis returned (e.g. Paystack), the Paystack modal can show a QR code for that URL so the user can scan and pay on another device. - Manual / till: An “I paid at till / agent” (or similar) button that POSTs
payment_method: "manual"toinitiate_url; the backend calls treasury-api initiate with"manual"or confirm-manual, then the UI redirects toredirect_url.
This applies in all services that use the shared pay page and in treasury-ui itself when it hosts payment flows.
6. Callback Page (After Payment)¶
After Paystack (or other redirect-based gateways) complete, the user lands on a callback page with ?reference=... (and optionally redirect_url, button_text). See paystack-callback-page.md. The callback page is public; it may call a verify endpoint and then show a single button (custom text and URL) to continue.
7. Webhooks & Auto-Generated URLs¶
- Webhook and callback URLs for Paystack and M-Pesa are auto-generated in treasury-api from
HTTP_PUBLIC_BASE_URLand fixed paths. Gateway credentials are platform-level configuration by default; the one tenant-level exception is a tenant connecting its own Paystack account (section 7a). External integrators never set gateway credentials. - Paystack webhooks must be signed (
x-paystack-signature). The signature is checked with the platform key, or with the tenant's own key when the payment belongs to a tenant-owned Paystack account. Unsigned or wrongly signed events get401. - Production base URL is set in
devops-k8s/apps/treasury-api/values.yamlasTREASURY_HTTP_PUBLIC_BASE_URL. - Idempotency key for
treasury.payment.succeeded(and every other treasury event): use the event envelope'sid(event_id) — it is always present and DB-unique, and is preserved across outbox retries and NATS redelivery. Do not key idempotency onprovider_reference/payment_ref: it is omitted entirely for cash/manual/till/COD settlements, and even when a gateway supplies one it is not enforced unique. See Idempotency & the Outbox Pattern for the consumer-sideIdempotencyStorepattern.
7a. Tenant-Owned Paystack Accounts¶
By default the platform's Paystack account charges customers and the platform then pays each tenant out on its payout schedule. A tenant can instead connect its own Paystack account; Paystack then settles that tenant's payments to it directly and the platform never holds or pays out that money.
| Platform account (default) | Tenant's own account | |
|---|---|---|
| Charges the customer | Platform keys | Tenant keys |
| Money lands in | Platform balance (or tenant subaccount split) | Tenant's Paystack balance |
| Platform payouts to the tenant | Yes | No |
| Webhook signature key | Platform secret | Tenant secret |
- Where: treasury-ui Settings > Payments > Paystack, "Paystack account" card. API:
GET/PUT/DELETE /api/v1/{tenant}/gateways/paystack/config(PUTbody{secret_key, public_key}; writes needtreasury.gateways.manage). - On save the keys must share a mode (both test or both live) and are checked against Paystack before anything is stored. The secret key is stored encrypted and is never returned or logged (only its last 4 characters).
- Webhook: the tenant pastes the webhook URL shown on the card into its Paystack dashboard.
- Payouts stay correct across a switch: each payment records who collected it (
metadata.collected_by:platformortenant) when it is started, and the payout engine pays out only what the platform collected. Disconnecting sends new payments back to the platform account.
8. Embedded Payment via TreasuryPaymentModal (Iframe)¶
For services that want to trigger payment without redirecting to treasury-ui directly, the @bengo-hub/shared-ui-lib provides TreasuryPaymentModal — a React component that embeds treasury-ui in a modal iframe.
import { TreasuryPaymentModal } from "@bengo-hub/shared-ui-lib/payments";
<TreasuryPaymentModal
isOpen={isOpen}
onClose={() => setIsOpen(false)}
intentId={intentId} // from treasury-api POST .../intents response
customerEmail={user.email}
onPaymentSuccess={(result) => handleSuccess(result)}
onPaymentFailed={(err) => handleError(err)}
/>
postMessage events (from iframe → parent):
- treasury:payment_initiated — gateway flow started
- treasury:payment_confirmed — payment succeeded
- treasury:payment_failed — payment failed
- treasury:resize — iframe height changed (handled automatically)
Paystack redirect URL (v0.1.5+) includes intent_id and amount as query params so the callback page can verify and display the correct order details.
Services using it: ordering-frontend, pos-ui, cafe-website, subscriptions-ui, notifications-ui, inventory-ui, truload-frontend — check the consuming service's package.json for the exact @bengo-hub/shared-ui-lib version in use, since it's updated frequently.
References¶
- Paystack callback page
- Treasury Service API Docs — live Swagger reference (treasury-api's own repo is private; this is the public, always-current source)
- Treasury-ui pay page:
finance-service/treasury-ui/src/app/(public)/pay/page.tsx - Payment modals:
finance-service/treasury-ui/src/components/payments/ - TreasuryPaymentModal:
shared/shared-ui-lib/src/components/payments/treasury-payment-modal.tsx