TKH Payments Service — Integration Guide
Audience: Engineers integrating any TKH product (Events, Telemedicine, School, etc.) with the shared TKH Payments microservice. Reference implementation: Kaba (accounting) — see Reference Implementation.
Table of Contents
- Overview
- Authentication
- API Reference
- Error Reference
- Integration Modes
- SNS Event Subscription
- Wiring Your App
- Gateway Routing
- Benin (BJ) — Full Payment Flow
- Refunds
- Local Development
- Cross-Account SNS Access
- App IDs
- Environment Variables
- FAQ
Overview
The TKH Payments microservice is a NestJS Lambda service that provides a single payment API across all TKH products. It auto-routes to the right gateway (Stripe, KkiaPay, MoMo, Paystack) based on the customer's country, handles webhook verification, and publishes clean SNS events your app can consume.
Your App Backend ──POST /intents──▶ Payments Service ──▶ Gateway API
│ ◀── gateway webhook (signature-verified)
└──SNS publish──▶ payment.completed
│
└──▶ Your Lambda handlerYour app never touches a gateway directly — you only:
- Create a payment intent (one API call)
- Listen for SNS events to know when payment is confirmed
Authentication
All /api/v1/* routes require an API key in the request header:
X-API-Key: <your-api-key>Webhook routes (/webhooks/*) are intentionally open — gateways cannot send custom headers.
Getting your API key: Contact the Payments team. One key is issued per product per environment. Store it in AWS SSM Parameter Store and inject it into your Lambda environment — never commit it to source code.
Local development: The key check is skipped when NODE_ENV=development and TKH_API_KEY_HASH is unset. You can develop and test without an API key locally.
API Reference
Base URLs
| Environment | Base URL |
|---|---|
| dev | https://hfy53j9rjc.execute-api.ca-central-1.amazonaws.com/dev/api/v1 |
| prod | Deploy with npm run cdk:deploy:prod — URL output as ApiBaseUrl |
POST /intents
Create a payment intent. Call this when a customer needs to pay for something (invoice, booking, appointment, etc.).
Request
POST {BASE_URL}/intents
Content-Type: application/json
X-API-Key: <your-api-key>{
"amount": 500000,
"currency": "XOF",
"country": "BJ",
"metadata": {
"appId": "events",
"referenceId": "booking-abc123",
"customerId": "cust-xyz",
"customerEmail": "user@example.com",
"phoneNumber": "+22961000000"
},
"returnUrl": "https://yourapp.com/payment/return",
"gatewayOverride": "kkiapay"
}Request Fields
| Field | Type | Required | Description |
|---|---|---|---|
amount | number | Yes | Amount in the smallest currency unit: francs for XOF/XAF/GNF, kobo for NGN, pesewas for GHS, cents for USD/EUR/CAD/GBP |
currency | string | Yes | ISO 4217 code: XOF, XAF, GNF, GHS, NGN, USD, EUR, CAD, GBP |
country | string | Yes | ISO 3166-1 alpha-2 code (BJ, NG, CA, etc.) — used to auto-select gateway |
metadata.appId | string | Yes | Your product's reserved App ID — see App IDs |
metadata.referenceId | string | Yes | Your domain object ID (e.g. booking-abc123, invoice-456) — used for reconciliation |
metadata.customerId | string | No | Your internal customer/user ID |
metadata.customerEmail | string | No | Customer email — required for Paystack redirect flow |
metadata.phoneNumber | string | No | Customer phone number — required for KkiaPay REST (Mode A) and MoMo |
returnUrl | string | No | Redirect URL after payment — required for Paystack |
gatewayOverride | string | No | Force a specific gateway: stripe, kkiapay, momo, paystack, tkh, mock |
useWidget | boolean | No | Use KkiaPay JS widget (Mode B) — only when gateway is KkiaPay. No phone required. |
Response — 201 Created
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"appId": "events",
"referenceId": "booking-abc123",
"amount": 500000,
"currency": "XOF",
"country": "BJ",
"status": "pending",
"gateway": "kkiapay",
"paymentUrl": null,
"clientSecret": null,
"gatewayTransactionId": "kkia_tx_abc123",
"paymentMode": "widget",
"metadata": { "appId": "events", "referenceId": "booking-abc123" },
"createdAt": "2026-03-08T12:00:00.000Z",
"updatedAt": "2026-03-08T12:00:00.000Z"
}Gateway-specific response fields
| Gateway | What you get | What to do |
|---|---|---|
| KkiaPay (Mode B widget) | paymentMode: "widget", gatewayTransactionId: null | Open KkiaPay widget on frontend. After success, call POST /intents/verify-kkiapay. |
| KkiaPay (Mode A) | gatewayTransactionId set, paymentUrl: null | Payment push sent to customer's phone. Wait for SNS event. |
| MoMo | gatewayTransactionId set, paymentUrl: null | Payment push sent to customer's phone. Wait for SNS event. |
| Paystack | paymentUrl: "https://checkout.paystack.com/..." | Redirect customer to paymentUrl. Wait for SNS event. |
| Stripe | clientSecret: "pi_xxx_secret_yyy" | Pass clientSecret to Stripe.js Elements on your frontend. |
| TKH | paymentUrl: "https://pay.tkhtech.com/checkout/..." | Redirect customer to paymentUrl. (TKH native gateway — evolving) |
| Mock (dev only) | paymentUrl: "https://mock-payment.tkhtech.local/pay/..." | Dev testing only — auto-succeeds on webhook. |
Intent Status Values
| Status | Meaning |
|---|---|
pending | Intent created, payment not yet initiated |
processing | Payment being processed by gateway |
succeeded | Payment confirmed — safe to fulfil |
failed | Payment failed |
cancelled | Intent cancelled |
GET /intents/:id
Retrieve the current state of a payment intent.
Request
GET {BASE_URL}/intents/{intentId}
X-API-Key: <your-api-key>Response — 200 OK
Same shape as the POST /intents response. Poll this to check status for redirect-based flows (Paystack, TKH).
Polling recommendation: Poll every 3–5 seconds with a max of 10 attempts. For production, rely on SNS events instead of polling.
POST /intents/:id/refund
Initiate a refund for a succeeded payment intent.
Request
POST {BASE_URL}/intents/{intentId}/refund
Content-Type: application/json
X-API-Key: <your-api-key>{
"amount": 250000,
"reason": "Customer requested cancellation"
}| Field | Type | Required | Description |
|---|---|---|---|
amount | number | No | Amount to refund in smallest currency unit. Omit for full refund. |
reason | string | No | Human-readable reason for the refund |
Response — 201 Created
{
"id": "refund-uuid",
"paymentId": "payment-uuid",
"intentId": "intent-uuid",
"amount": 250000,
"currency": "XOF",
"status": "succeeded",
"gateway": "stripe",
"gatewayRefundId": "re_stripe_abc123",
"reason": "Customer requested cancellation",
"createdAt": "2026-03-08T13:00:00.000Z",
"updatedAt": "2026-03-08T13:00:00.000Z"
}Refund status values
| Status | Meaning |
|---|---|
succeeded | Gateway refund confirmed (Stripe, Paystack) |
pending | Gateway does not support programmatic refunds (KkiaPay, MoMo, TKH) — process manually via gateway dashboard |
failed | Refund failed at gateway level |
Refund support by gateway
| Gateway | Programmatic Refund | Notes |
|---|---|---|
| Stripe | Yes — status: succeeded | Immediate via Stripe Refunds API |
| Paystack | Yes — status: succeeded | Via Paystack /refund endpoint |
| KkiaPay | No — status: pending | Manual via KkiaPay dashboard |
| MoMo | No — status: pending | Manual via MTN MoMo dashboard |
| TKH | No — status: pending | Will be supported in Phase 2 |
A payment.refunded SNS event is always published regardless of refund status.
POST /intents/verify-kkiapay
Verify a payment completed via the KkiaPay JS widget (Mode B). Call this from your backend after your frontend receives the transactionId from the widget's addSuccessListener.
This endpoint is only needed for KkiaPay JS widget (Mode B). It is not needed for Mode A, Stripe, Paystack, or MoMo.
Request
POST {BASE_URL}/intents/verify-kkiapay
Content-Type: application/json
X-API-Key: <your-api-key>{
"transactionId": "kkia_tx_abc123",
"intentId": "550e8400-e29b-41d4-a716-446655440000"
}| Field | Type | Required | Description |
|---|---|---|---|
transactionId | string | Yes | The transactionId returned by KkiaPay addSuccessListener |
intentId | string | Yes | The intent ID you received when you called POST /intents |
Response — 200 OK
{
"verified": true,
"intent": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"status": "succeeded",
"gateway": "kkiapay",
...
}
}After this call, a payment.completed SNS event is published. Your SNS Lambda handler will fire exactly as it does for Mode A — no changes to your event handler are needed.
Error Reference
All errors return a consistent JSON body:
{
"statusCode": 404,
"error": "NOT_FOUND",
"message": "PaymentIntent not found: 550e8400..."
}| HTTP Status | error code | When it happens |
|---|---|---|
400 | VALIDATION_ERROR | Missing or invalid request fields |
401 | UNAUTHORIZED | Missing or invalid X-API-Key |
404 | NOT_FOUND | Intent not found |
402 | PAYMENT_ERROR | Payment-specific business logic error |
502 | PAYMENT_GATEWAY_ERROR | Upstream gateway failed or is unavailable |
429 | — | Rate limit exceeded (100 requests/60s per IP) |
500 | — | Unexpected internal error |
Error handling in your PaymentsClient:
const result = await paymentsClient.createIntent({ ... });
if (!result.success) {
// result.error contains the human-readable message
// Surface to the user or log for ops
throw new Error(`Payment failed: ${result.error}`);
}Integration Modes
Mode A — Backend REST (recommended)
Your backend calls the Payments service directly. The gateway sends a webhook back to the Payments service when the payment completes. You receive a clean payment.completed SNS event.
Best for: All gateways. Required for MoMo, Paystack, Stripe. Optional for KkiaPay (vs. Mode B).
Your Backend ──POST /intents──▶ Payments Service ──▶ Gateway API
│ ◀── gateway webhook (signature-verified inside service)
└──SNS──▶ payment.completed ──▶ Your Lambda handlerFlow:
- Customer initiates checkout in your app
- Your backend calls
POST /intents - Based on gateway:
- Paystack / TKH: redirect customer to
paymentUrl - Stripe: send
clientSecretto your frontend for Stripe Elements - KkiaPay / MoMo: confirm push sent; show "Check your phone" UI
- Paystack / TKH: redirect customer to
- Gateway notifies Payments service via webhook
- Payments service publishes
payment.completedSNS event - Your SNS Lambda handler receives the event and marks the order as paid
Mode B — KkiaPay JS Widget
Your frontend opens the KkiaPay-hosted widget directly in the browser. Use this when you have a web frontend and want the best KkiaPay UX (the widget handles phone entry, OTP, and confirmation natively).
Your Frontend ──openWidget──▶ KkiaPay JS Widget (browser)
│ customer pays
└──addSuccessListener({ transactionId })
│
▼
Your Backend ──POST /intents/verify-kkiapay──▶ Payments Service
└──SNS──▶ payment.completedFlow:
- Create an intent:
POST /intentswithuseWidget: true(andcountry: "BJ"orgatewayOverride: "kkiapay") - Response includes
paymentMode: "widget"— frontend opens the KkiaPay widget - Store the returned
intentId - Open the KkiaPay widget on your frontend
addSuccessListenerfires withtransactionId- Your frontend sends
transactionId+intentIdto your backend - Your backend calls
POST /intents/verify-kkiapay - Payments service verifies with KkiaPay, marks intent succeeded, publishes SNS event
- Your SNS Lambda handler fires as usual
Frontend Setup
Load the KkiaPay JS SDK:
<!-- In your HTML head -->
<script src="https://cdn.kkiapay.me/k.js"></script>For Next.js:
import Script from 'next/script';
// Add to your payment page component
<Script src="https://cdn.kkiapay.me/k.js" strategy="beforeInteractive" />Add global TypeScript types (create kkiapay.d.ts in your project):
declare function openKkiapayWidget(options: {
amount: number;
currency?: string;
position?: 'left' | 'right' | 'center';
key: string; // your KkiaPay PUBLIC key
sandbox?: boolean;
phone?: string;
name?: string;
email?: string;
partnerId?: string; // pass your referenceId here for reconciliation
paymentmethod?: 'momo' | 'card' | 'wallet';
countries?: string[];
callback?: string;
}): void;
declare function addSuccessListener(
callback: (response: { transactionId: string; [key: string]: unknown }) => void
): void;
declare function addFailedListener(
callback: (response: { message?: string; [key: string]: unknown }) => void
): void;
declare function addCloseListener(callback: () => void): void;Open the widget:
function openKkiapayPayment(options: {
amount: number;
currency: string;
intentId: string; // from POST /intents
referenceId: string;
customerPhone?: string;
customerEmail?: string;
onSuccess: (transactionId: string) => void;
onFailed?: (err: unknown) => void;
}) {
openKkiapayWidget({
amount: options.amount,
currency: options.currency,
position: 'center',
key: process.env.NEXT_PUBLIC_KKIAPAY_PUBLIC_KEY!,
sandbox: process.env.NODE_ENV !== 'production',
phone: options.customerPhone,
email: options.customerEmail,
partnerId: options.referenceId,
paymentmethod: 'momo',
countries: ['BJ', 'CI', 'TG', 'SN', 'GN', 'ML', 'NE', 'BF', 'CM'],
});
addSuccessListener(({ transactionId }) => {
options.onSuccess(transactionId);
});
addFailedListener((err) => {
console.error('KkiaPay payment failed', err);
options.onFailed?.(err);
});
}After addSuccessListener fires, verify on your backend:
// Frontend — send to your backend (NOT directly to Payments service)
async function onKkiapaySuccess(transactionId: string, intentId: string) {
const res = await fetch('/api/payments/verify-kkiapay', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ transactionId, intentId }),
});
const data = await res.json();
if (data.verified) {
// Show success UI — SNS event will fire to your Lambda
}
}Your backend routes the verification to the Payments service:
// Your NestJS controller
@Post('payments/verify-kkiapay')
async verifyKkiapay(@Body() body: { transactionId: string; intentId: string }) {
return this.paymentsClient.verifyKkiapay(body.transactionId, body.intentId);
}Security: Never trust the frontend directly. Always verify via your backend → Payments service. The
POST /intents/verify-kkiapaycall checks the transaction with KkiaPay's server-side API before marking anything as paid.
SNS Event Subscription
The Payments service publishes to an SNS topic when a payment status changes. Subscribe your Lambda to receive these events.
SNS Topic ARNs
| Environment | Topic ARN |
|---|---|
| dev | arn:aws:sns:ca-central-1:497172038983:tkhtech-payment-events-dev |
| prod | arn:aws:sns:ca-central-1:497172038983:tkhtech-payment-events-prod |
Event Schema
Every SNS message has this JSON body:
{
"type": "payment.completed",
"intentId": "550e8400-e29b-41d4-a716-446655440000",
"paymentId": "payment-uuid",
"refundId": null,
"appId": "kaba",
"referenceId": "invoice-abc123",
"businessId": "biz-xyz",
"amount": 500000,
"currency": "XOF",
"gateway": "kkiapay",
"timestamp": "2026-03-08T12:05:00.000Z"
}| Field | Type | Description |
|---|---|---|
type | string | Event type — see below |
intentId | string | The payment intent ID |
paymentId | string | null | Set on payment.completed |
refundId | string | null | Set on payment.refunded |
appId | string | Which TKH app this belongs to |
referenceId | string | Your domain object ID (e.g. invoice ID) |
businessId | string | undefined | The business/tenant ID — passed from metadata.businessId in the intent. Required by Kaba's SNS handler to look up the invoice. |
amount | number | Amount in smallest currency unit |
currency | string | ISO 4217 currency code |
gateway | string | Which gateway processed the payment |
timestamp | string | ISO 8601 timestamp |
Event Types
type | When it fires | What to do |
|---|---|---|
payment.completed | Payment confirmed by gateway | Fulfil the order/service. Idempotent — check if already processed. |
payment.failed | Payment failed at gateway | Notify the customer. Do not fulfil. |
payment.refunded | Refund initiated (may be pending for dashboard-only gateways) | Update your records. Check refundId for the refund details. |
disbursement.completed | Auto-disburse to business succeeded (Benin KkiaPay flow) | Log for reconciliation. |
disbursement.failed | Auto-disburse to business failed (Benin KkiaPay flow) | Alert ops — manually trigger POST /disbursements. |
SNS Message Attributes (for filtering)
Each message includes these attributes. Use them to set subscription filter policies so your Lambda only receives events for your app:
| Attribute | Values |
|---|---|
eventType | payment.completed | payment.failed | payment.refunded |
appId | your app's appId (e.g. events) |
Wiring Your App
Payments Client
Copy this into your NestJS app and adapt it. Update X-API-Key injection.
// src/domains/payments/services/PaymentsClient.ts
import { Injectable, Logger } from '@nestjs/common';
export interface CreateIntentRequest {
amount: number;
currency: string;
country: string;
metadata: {
appId: string;
referenceId: string;
customerId?: string;
customerEmail?: string;
phoneNumber?: string;
[key: string]: string | undefined;
};
returnUrl?: string;
gatewayOverride?: string;
}
export interface CreateIntentResult {
success: boolean;
intentId?: string;
paymentUrl?: string;
clientSecret?: string;
error?: string;
}
@Injectable()
export class PaymentsClient {
private readonly logger = new Logger(PaymentsClient.name);
private readonly baseUrl: string;
private readonly apiKey: string;
constructor() {
this.baseUrl = (process.env['PAYMENTS_SERVICE_URL'] ?? '').replace(/\/$/, '');
this.apiKey = process.env['TKH_PAYMENTS_API_KEY'] ?? '';
}
async createIntent(request: CreateIntentRequest): Promise<CreateIntentResult> {
if (!this.baseUrl) throw new Error('PAYMENTS_SERVICE_URL is not configured');
try {
const res = await fetch(`${this.baseUrl}/intents`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': this.apiKey,
},
body: JSON.stringify(request),
signal: AbortSignal.timeout(10_000),
});
const data = await res.json().catch(() => ({})) as {
id?: string;
paymentUrl?: string;
clientSecret?: string;
message?: string;
error?: string;
};
if (!res.ok) {
this.logger.error(`Payments service error ${res.status}: ${data.message ?? data.error}`);
return { success: false, error: data.message ?? data.error ?? `HTTP ${res.status}` };
}
return {
success: true,
intentId: data.id,
paymentUrl: data.paymentUrl,
clientSecret: data.clientSecret,
};
} catch (err) {
this.logger.error(`Payments service unreachable: ${(err as Error).message}`);
return { success: false, error: (err as Error).message };
}
}
async getIntent(intentId: string): Promise<{ success: boolean; intent?: unknown; error?: string }> {
try {
const res = await fetch(`${this.baseUrl}/intents/${intentId}`, {
headers: { 'X-API-Key': this.apiKey },
signal: AbortSignal.timeout(5_000),
});
if (!res.ok) return { success: false, error: `HTTP ${res.status}` };
return { success: true, intent: await res.json() };
} catch (err) {
return { success: false, error: (err as Error).message };
}
}
async refund(intentId: string, amount?: number, reason?: string): Promise<{ success: boolean; refund?: unknown; error?: string }> {
try {
const res = await fetch(`${this.baseUrl}/intents/${intentId}/refund`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-API-Key': this.apiKey },
body: JSON.stringify({ amount, reason }),
signal: AbortSignal.timeout(10_000),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) return { success: false, error: (data as any).message ?? `HTTP ${res.status}` };
return { success: true, refund: data };
} catch (err) {
return { success: false, error: (err as Error).message };
}
}
async verifyKkiapay(transactionId: string, intentId: string): Promise<{ success: boolean; verified?: boolean; intent?: unknown; error?: string }> {
try {
const res = await fetch(`${this.baseUrl}/intents/verify-kkiapay`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-API-Key': this.apiKey },
body: JSON.stringify({ transactionId, intentId }),
signal: AbortSignal.timeout(10_000),
});
const data = await res.json().catch(() => ({})) as { verified?: boolean; intent?: unknown; message?: string };
if (!res.ok) return { success: false, error: data.message ?? `HTTP ${res.status}` };
return { success: true, verified: data.verified, intent: data.intent };
} catch (err) {
return { success: false, error: (err as Error).message };
}
}
}SNS Lambda Handler
// src/infrastructure/handlers/payment-event.ts
import type { SNSEvent } from 'aws-lambda';
interface PaymentEvent {
type: 'payment.completed' | 'payment.failed' | 'payment.refunded';
intentId: string;
paymentId?: string;
refundId?: string;
appId: string;
referenceId: string;
amount: number;
currency: string;
gateway: string;
timestamp: string;
}
const MY_APP_ID = 'events'; // replace with your appId
export async function handler(event: SNSEvent): Promise<void> {
for (const record of event.Records) {
try {
const payload = JSON.parse(record.Sns.Message) as PaymentEvent;
// Filter: only process events for this app
if (payload.appId !== MY_APP_ID) continue;
switch (payload.type) {
case 'payment.completed':
await onPaymentCompleted(payload);
break;
case 'payment.failed':
await onPaymentFailed(payload);
break;
case 'payment.refunded':
await onPaymentRefunded(payload);
break;
}
} catch (err) {
console.error('Error processing payment event:', err);
// Do NOT rethrow — let SNS retry with exponential backoff
}
}
}
async function onPaymentCompleted(event: PaymentEvent) {
console.log(`[payment.completed] ref=${event.referenceId} amount=${event.amount} ${event.currency} via ${event.gateway}`);
// TODO: mark your domain object as paid
// IMPORTANT: Make this idempotent — check if already processed before acting
}
async function onPaymentFailed(event: PaymentEvent) {
console.log(`[payment.failed] ref=${event.referenceId}`);
// TODO: notify customer, update order status
}
async function onPaymentRefunded(event: PaymentEvent) {
console.log(`[payment.refunded] ref=${event.referenceId} refundId=${event.refundId}`);
// TODO: update booking/invoice status
}CDK Wiring
// In your CDK stack
import * as cdk from 'aws-cdk-lib';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as sns from 'aws-cdk-lib/aws-sns';
import * as subs from 'aws-cdk-lib/aws-sns-subscriptions';
// ── Reference the Payments SNS topic (cross-account) ─────────
const paymentsTopicArn = environment === 'prod'
? 'arn:aws:sns:ca-central-1:497172038983:tkhtech-payment-events-prod'
: 'arn:aws:sns:ca-central-1:497172038983:tkhtech-payment-events-dev';
const paymentsTopic = sns.Topic.fromTopicArn(this, 'PaymentsTopic', paymentsTopicArn);
// ── Payment event Lambda ──────────────────────────────────────
const paymentEventLambda = new lambda.Function(this, 'PaymentEventHandler', {
runtime: lambda.Runtime.NODEJS_20_X,
handler: 'handler.handler',
code: lambda.Code.fromAsset('dist/payment-event-lambda'),
timeout: cdk.Duration.seconds(30),
memorySize: 256,
environment: {
// Your app-specific env vars
DYNAMODB_TABLE: myTable.tableName,
},
});
myTable.grantReadWriteData(paymentEventLambda);
// ── Subscribe to payments topic (filter to your appId only) ──
paymentsTopic.addSubscription(
new subs.LambdaSubscription(paymentEventLambda, {
filterPolicy: {
appId: sns.SubscriptionFilter.stringFilter({ allowlist: ['events'] }), // your appId
},
}),
);
// ── Inject Payments service URL + API key into your API Lambda
const paymentsServiceUrl = environment === 'prod'
? 'https://<prod-api-id>.execute-api.ca-central-1.amazonaws.com/prod/api/v1'
: 'https://hfy53j9rjc.execute-api.ca-central-1.amazonaws.com/dev/api/v1';
// Add to your main API Lambda environment:
// PAYMENTS_SERVICE_URL: paymentsServiceUrl,
// PAYMENTS_API_KEY: ssm.StringParameter.valueForStringParameter(this, '/your-app/<env>/payments-api-key'),Gateway Routing
The service selects the gateway automatically based on the customer's country. You can override with gatewayOverride.
| Country | Primary | Fallback 1 | Fallback 2 |
|---|---|---|---|
| BJ, TG, CI, SN, ML, NE, BF, CM, GA, CG, TD, GN | KkiaPay | MoMo | Stripe |
| GH (Ghana) | MoMo | KkiaPay | Stripe |
| NG (Nigeria) | Paystack | Stripe | MoMo |
| CA, US, GB, FR | Stripe | TKH | — |
| All others | Stripe | KkiaPay | MoMo → Paystack |
Fallbacks only apply when the next gateway is configured with valid credentials.
gatewayOverride values: stripe, kkiapay, momo, paystack, tkh, mock
Use
mockin development/test only. In dev,NODE_ENV=development, the mock gateway is always registered and supports all currencies.
Benin (BJ) — Full Payment Flow
Benin is the primary target market. The flow uses KkiaPay to collect from the customer and then automatically disburses to the business's mobile money account.
Customer (BJ) KkiaPay Payments Service Business (BJ)
│ │ │ │
│◀── USSD push ─────│◀── POST /intents ───│ │
│── approves ───────▶│ │ │
│ │── webhook ───────────▶│ │
│ │ │── payment.completed SNS │
│ │ │── POST /disbursements ──▶│
│ │ │ (MoMo/Moov Africa) │
│ │ │ XOF ▶│ business MoMo walletHow it works
- Your backend calls
POST /intentswith the customer's phone and the business's MoMo phone:
{
"amount": 500000,
"currency": "XOF",
"country": "BJ",
"metadata": {
"appId": "kaba",
"referenceId": "invoice-abc123",
"businessId": "biz-xyz",
"phoneNumber": "+22961111111",
"businessPhone": "+22997222222"
}
}| Field | Description |
|---|---|
metadata.phoneNumber | Customer's phone — receives the KkiaPay USSD push |
metadata.businessPhone | Business's MoMo phone — receives the disbursement after payment |
metadata.businessId | Your internal business/tenant ID — included in all SNS events |
KkiaPay sends a USSD push to the customer's phone. The customer approves on their phone.
KkiaPay sends a webhook to the payments service. The service verifies it and creates the payment record.
Auto-disburse fires immediately (fire-and-forget): the payments service calls
POST /disbursementsto send the money tobusinessPhonevia MTN MoMo (primary) or Moov Africa (fallback).Two SNS events fire:
payment.completed— your SNS handler marks the invoice as paiddisbursement.completed(ordisbursement.failed) — you can log/alert on this
What if businessPhone is missing?
If businessPhone is not in the intent metadata, the payment is still confirmed and payment.completed fires normally. The auto-disburse is skipped and a warning is logged:
WARN: KkiaPay payment <id> completed but no businessPhone in metadata — manual disburse requiredYou can then manually trigger POST /disbursements with the business's phone.
What if auto-disburse fails?
The payment confirmation is not affected — payment.completed always fires as soon as KkiaPay confirms the payment. Disburse failures are logged and a disbursement.failed SNS event fires. You can handle this in your SNS handler (e.g. alert ops, retry).
Kaba integration (automatic)
Kaba passes businessPhone automatically from the business record when generating a payment link. No extra code needed — it reads business.phone and passes it as metadata.businessPhone.
Refunds
Call the refund endpoint when a customer requests a cancellation or return.
POST {BASE_URL}/intents/{intentId}/refund
Content-Type: application/json
X-API-Key: <your-api-key>
{
"amount": 250000,
"reason": "Customer requested cancellation"
}- Omit
amountfor a full refund - The intent must have
status: succeededto initiate a refund - Stripe and Paystack refunds are processed immediately (
status: succeeded) - KkiaPay and MoMo return
status: pending— you must process the refund manually through their dashboards - A
payment.refundedSNS event is always published
Local Development
Option A — Run the Payments service locally
# In the payments project
cp .env.example .env
# Fill in at minimum: NODE_ENV=development
npm run dev
# Listening on http://localhost:3002Then in your app:
PAYMENTS_SERVICE_URL=http://localhost:3002/api/v1 npm run devOption B — Use the mock gateway (no local Payments service)
Add gatewayOverride: 'mock' to your createIntent call. The mock gateway returns a fake paymentUrl, supports all currencies, and auto-succeeds on webhook. Available whenever NODE_ENV=development.
API Key in local dev: Leave TKH_API_KEY_HASH unset in the Payments service .env — the key check is skipped automatically in development mode. Set PAYMENTS_API_KEY to any value in your app.
Cross-Account SNS Access
The Payments SNS topic lives in the TKH Payments AWS account (497172038983). To subscribe your Lambda from a different account, the Payments team must add your account ID to environments.ts and redeploy.
What to provide to the Payments team:
- Your AWS account ID (one per environment)
- Your
appId - Which environment (dev, prod)
Open a PR in the payments project:
// payments/src/infrastructure/config/environments.ts
dev: {
subscriberAccountIds: [
'110044886269', // Kaba dev
'307869868537', // Events dev
'YOUR_ACCOUNT_ID', // Your app dev
],
},App IDs
Each TKH product has a reserved appId. Do not reuse another product's ID — it affects event routing, reconciliation, and monitoring.
| Product | appId |
|---|---|
| Kaba (accounting) | kaba |
| Events | events |
| Telemedicine | telemedicine |
| School Management | school |
To register a new appId, add it to the AppId type in payments/src/domains/payments/models/Payment.ts and open a PR.
Environment Variables
In your app (your API Lambda):
| Variable | Description |
|---|---|
PAYMENTS_SERVICE_URL | Base URL of the Payments API (no trailing slash). Example: https://hfy53j9rjc.execute-api.ca-central-1.amazonaws.com/dev/api/v1 |
TKH_PAYMENTS_API_KEY | Your API key for the X-API-Key header. Get from the Payments team. Store in SSM. Leave empty locally (auth is disabled in dev). |
Frontend only (KkiaPay JS widget):
| Variable | Description |
|---|---|
NEXT_PUBLIC_KKIAPAY_PUBLIC_KEY | KkiaPay public key — safe to expose in browser. Never use the private key in frontend code. |
FAQ
Q: Do I need to verify webhooks myself? No. Webhook signature verification happens inside the Payments service. Your SNS handler receives clean, validated events — no signature headers to deal with.
Q: My Lambda is in a different AWS account — how do I subscribe to the SNS topic? See Cross-Account SNS Access. You need the Payments team to allowlist your account ID. This is a 5-minute change + redeploy.
Q: Can I filter SNS events to only receive my app's events? Yes — use a subscription filter policy on the appId attribute (shown in the CDK wiring section). Without filtering, all TKH app events arrive in your Lambda and you must filter in code.
Q: How do I handle the case where the SNS event fires before my handler is ready? SNS retries for up to 23 days with exponential backoff. Always make your handler idempotent — check if you've already processed referenceId before acting. Use DynamoDB conditional writes or a processed-IDs set.
Q: What if the Payments service is down when I call createIntent?PaymentsClient catches network errors and returns { success: false, error: '...' }. Surface this as a user-facing error and let the customer retry. Do not assume the intent was created.
Q: Can I use gatewayOverride in production? Yes — use it when a customer explicitly chooses a payment method. Otherwise, auto-routing by country gives the best success rate.
Q: How do I tell my app to use the KkiaPay widget when payment is for a KkiaPay country? Pass useWidget: true when creating the intent. The gateway is auto-selected by country (BJ, TG, CI, etc. → KkiaPay). The response will include paymentMode: "widget" — your frontend checks this and opens the widget instead of waiting for a push. Example:
POST /intents
{ "amount": 500000, "currency": "XOF", "country": "BJ", "metadata": { "appId": "kaba", "referenceId": "inv-1" }, "useWidget": true }Response: { "id": "...", "gateway": "kkiapay", "paymentMode": "widget", ... } → frontend opens widget.
Q: Should I use KkiaPay Mode A or Mode B? Use Mode B (JS widget) if you have a web frontend — it gives the best UX (widget handles phone entry, OTP, confirmation). Use Mode A (REST) only for server-to-server flows or mobile apps that already have the customer's phone number and want a silent push.
Q: Why does KkiaPay REST (Mode A) not return a paymentUrl? KkiaPay REST is an async mobile money push — there is no checkout page. The Payments service calls KkiaPay's API, which sends a payment request to the customer's phone. The customer approves on their device, then KkiaPay posts a webhook to the Payments service, which publishes the SNS event.
Q: What is the TKH gateway?gatewayOverride: 'tkh' routes through TKH's own emerging payment gateway — currently a redirect to pay.tkhtech.com. As TKH builds direct acquiring relationships, this gateway will process payments natively. For now, use it only if directed to do so by the Payments team.
Q: What happens if I send the same referenceId twice? Each POST /intents creates a new intent with a new UUID. There is no deduplication by referenceId on creation. For idempotency, query GET /intents/:id before creating a new one, or handle the payment.completed event idempotently in your SNS handler.
Q: How do I test refunds locally? Use gatewayOverride: 'mock' — the mock gateway's refund always returns status: succeeded. For real gateway refund testing, use Stripe test mode or Paystack test keys.
Reference Implementation — Kaba
| File | Purpose |
|---|---|
projects/quickbooks/backend/src/domains/payments/services/PaymentsClient.ts | Full HTTP client — sends TKH_PAYMENTS_API_KEY in X-API-Key header, passes businessPhone in metadata |
projects/quickbooks/backend/src/infrastructure/handlers/payment-event.ts | SNS Lambda handler — marks invoice paid, creates ledger entry, reads businessId from event |
projects/quickbooks/backend/src/infrastructure/stacks/KabaApiStack.ts | CDK wiring — Lambda + SNS subscription + env vars + filter policy |
projects/quickbooks/backend/src/infrastructure/config/environments.ts | paymentsServiceUrl, tkhPaymentsApiKey, paymentsSnsTopicArn per env |
AWS Account IDs
| Account | ID |
|---|---|
| TKH Payments (service owner) | 497172038983 |
| Kaba dev | 110044886269 |
| Events dev | 307869868537 |