Skip to content

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:

  1. 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".
  2. Treasury-api returns intent_id and invoice details (amount, currency, reference_id, reference_type).
  3. 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.
  4. 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.
  5. 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_service is 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_service to 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". Optional gateway pins 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_method is the rail (airtel_money, mtn_momo, payhero_momo, payhero_card, payhero_bank) with gateway: "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_url is 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" to initiate_url; the backend calls treasury-api initiate with "manual" or confirm-manual, then the UI redirects to redirect_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_URL and 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 get 401.
  • Production base URL is set in devops-k8s/apps/treasury-api/values.yaml as TREASURY_HTTP_PUBLIC_BASE_URL.
  • Idempotency key for treasury.payment.succeeded (and every other treasury event): use the event envelope's id (event_id) — it is always present and DB-unique, and is preserved across outbox retries and NATS redelivery. Do not key idempotency on provider_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-side IdempotencyStore pattern.

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 (PUT body {secret_key, public_key}; writes need treasury.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: platform or tenant) 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