Skip to content

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

  1. Overview
  2. Authentication
  3. API Reference
  4. Error Reference
  5. Integration Modes
  6. SNS Event Subscription
  7. Wiring Your App
  8. Gateway Routing
  9. Benin (BJ) — Full Payment Flow
  10. Refunds
  11. Local Development
  12. Cross-Account SNS Access
  13. App IDs
  14. Environment Variables
  15. 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 handler

Your app never touches a gateway directly — you only:

  1. Create a payment intent (one API call)
  2. 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

EnvironmentBase URL
devhttps://hfy53j9rjc.execute-api.ca-central-1.amazonaws.com/dev/api/v1
prodDeploy 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>
json
{
  "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

FieldTypeRequiredDescription
amountnumberYesAmount in the smallest currency unit: francs for XOF/XAF/GNF, kobo for NGN, pesewas for GHS, cents for USD/EUR/CAD/GBP
currencystringYesISO 4217 code: XOF, XAF, GNF, GHS, NGN, USD, EUR, CAD, GBP
countrystringYesISO 3166-1 alpha-2 code (BJ, NG, CA, etc.) — used to auto-select gateway
metadata.appIdstringYesYour product's reserved App ID — see App IDs
metadata.referenceIdstringYesYour domain object ID (e.g. booking-abc123, invoice-456) — used for reconciliation
metadata.customerIdstringNoYour internal customer/user ID
metadata.customerEmailstringNoCustomer email — required for Paystack redirect flow
metadata.phoneNumberstringNoCustomer phone number — required for KkiaPay REST (Mode A) and MoMo
returnUrlstringNoRedirect URL after payment — required for Paystack
gatewayOverridestringNoForce a specific gateway: stripe, kkiapay, momo, paystack, tkh, mock
useWidgetbooleanNoUse KkiaPay JS widget (Mode B) — only when gateway is KkiaPay. No phone required.

Response — 201 Created

json
{
  "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

GatewayWhat you getWhat to do
KkiaPay (Mode B widget)paymentMode: "widget", gatewayTransactionId: nullOpen KkiaPay widget on frontend. After success, call POST /intents/verify-kkiapay.
KkiaPay (Mode A)gatewayTransactionId set, paymentUrl: nullPayment push sent to customer's phone. Wait for SNS event.
MoMogatewayTransactionId set, paymentUrl: nullPayment push sent to customer's phone. Wait for SNS event.
PaystackpaymentUrl: "https://checkout.paystack.com/..."Redirect customer to paymentUrl. Wait for SNS event.
StripeclientSecret: "pi_xxx_secret_yyy"Pass clientSecret to Stripe.js Elements on your frontend.
TKHpaymentUrl: "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

StatusMeaning
pendingIntent created, payment not yet initiated
processingPayment being processed by gateway
succeededPayment confirmed — safe to fulfil
failedPayment failed
cancelledIntent 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>
json
{
  "amount": 250000,
  "reason": "Customer requested cancellation"
}
FieldTypeRequiredDescription
amountnumberNoAmount to refund in smallest currency unit. Omit for full refund.
reasonstringNoHuman-readable reason for the refund

Response — 201 Created

json
{
  "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

StatusMeaning
succeededGateway refund confirmed (Stripe, Paystack)
pendingGateway does not support programmatic refunds (KkiaPay, MoMo, TKH) — process manually via gateway dashboard
failedRefund failed at gateway level

Refund support by gateway

GatewayProgrammatic RefundNotes
StripeYes — status: succeededImmediate via Stripe Refunds API
PaystackYes — status: succeededVia Paystack /refund endpoint
KkiaPayNo — status: pendingManual via KkiaPay dashboard
MoMoNo — status: pendingManual via MTN MoMo dashboard
TKHNo — status: pendingWill 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>
json
{
  "transactionId": "kkia_tx_abc123",
  "intentId": "550e8400-e29b-41d4-a716-446655440000"
}
FieldTypeRequiredDescription
transactionIdstringYesThe transactionId returned by KkiaPay addSuccessListener
intentIdstringYesThe intent ID you received when you called POST /intents

Response — 200 OK

json
{
  "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:

json
{
  "statusCode": 404,
  "error": "NOT_FOUND",
  "message": "PaymentIntent not found: 550e8400..."
}
HTTP Statuserror codeWhen it happens
400VALIDATION_ERRORMissing or invalid request fields
401UNAUTHORIZEDMissing or invalid X-API-Key
404NOT_FOUNDIntent not found
402PAYMENT_ERRORPayment-specific business logic error
502PAYMENT_GATEWAY_ERRORUpstream gateway failed or is unavailable
429Rate limit exceeded (100 requests/60s per IP)
500Unexpected internal error

Error handling in your PaymentsClient:

typescript
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

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 handler

Flow:

  1. Customer initiates checkout in your app
  2. Your backend calls POST /intents
  3. Based on gateway:
    • Paystack / TKH: redirect customer to paymentUrl
    • Stripe: send clientSecret to your frontend for Stripe Elements
    • KkiaPay / MoMo: confirm push sent; show "Check your phone" UI
  4. Gateway notifies Payments service via webhook
  5. Payments service publishes payment.completed SNS event
  6. 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.completed

Flow:

  1. Create an intent: POST /intents with useWidget: true (and country: "BJ" or gatewayOverride: "kkiapay")
  2. Response includes paymentMode: "widget" — frontend opens the KkiaPay widget
  3. Store the returned intentId
  4. Open the KkiaPay widget on your frontend
  5. addSuccessListener fires with transactionId
  6. Your frontend sends transactionId + intentId to your backend
  7. Your backend calls POST /intents/verify-kkiapay
  8. Payments service verifies with KkiaPay, marks intent succeeded, publishes SNS event
  9. Your SNS Lambda handler fires as usual

Frontend Setup

Load the KkiaPay JS SDK:

html
<!-- In your HTML head -->
<script src="https://cdn.kkiapay.me/k.js"></script>

For Next.js:

tsx
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):

typescript
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:

typescript
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:

typescript
// 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:

typescript
// 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-kkiapay call 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

EnvironmentTopic ARN
devarn:aws:sns:ca-central-1:497172038983:tkhtech-payment-events-dev
prodarn:aws:sns:ca-central-1:497172038983:tkhtech-payment-events-prod

Event Schema

Every SNS message has this JSON body:

json
{
  "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"
}
FieldTypeDescription
typestringEvent type — see below
intentIdstringThe payment intent ID
paymentIdstring | nullSet on payment.completed
refundIdstring | nullSet on payment.refunded
appIdstringWhich TKH app this belongs to
referenceIdstringYour domain object ID (e.g. invoice ID)
businessIdstring | undefinedThe business/tenant ID — passed from metadata.businessId in the intent. Required by Kaba's SNS handler to look up the invoice.
amountnumberAmount in smallest currency unit
currencystringISO 4217 currency code
gatewaystringWhich gateway processed the payment
timestampstringISO 8601 timestamp

Event Types

typeWhen it firesWhat to do
payment.completedPayment confirmed by gatewayFulfil the order/service. Idempotent — check if already processed.
payment.failedPayment failed at gatewayNotify the customer. Do not fulfil.
payment.refundedRefund initiated (may be pending for dashboard-only gateways)Update your records. Check refundId for the refund details.
disbursement.completedAuto-disburse to business succeeded (Benin KkiaPay flow)Log for reconciliation.
disbursement.failedAuto-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:

AttributeValues
eventTypepayment.completed | payment.failed | payment.refunded
appIdyour 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.

typescript
// 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

typescript
// 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

typescript
// 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.

CountryPrimaryFallback 1Fallback 2
BJ, TG, CI, SN, ML, NE, BF, CM, GA, CG, TD, GNKkiaPayMoMoStripe
GH (Ghana)MoMoKkiaPayStripe
NG (Nigeria)PaystackStripeMoMo
CA, US, GB, FRStripeTKH
All othersStripeKkiaPayMoMo → Paystack

Fallbacks only apply when the next gateway is configured with valid credentials.

gatewayOverride values: stripe, kkiapay, momo, paystack, tkh, mock

Use mock in 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 wallet

How it works

  1. Your backend calls POST /intents with the customer's phone and the business's MoMo phone:
json
{
  "amount": 500000,
  "currency": "XOF",
  "country": "BJ",
  "metadata": {
    "appId": "kaba",
    "referenceId": "invoice-abc123",
    "businessId": "biz-xyz",
    "phoneNumber": "+22961111111",
    "businessPhone": "+22997222222"
  }
}
FieldDescription
metadata.phoneNumberCustomer's phone — receives the KkiaPay USSD push
metadata.businessPhoneBusiness's MoMo phone — receives the disbursement after payment
metadata.businessIdYour internal business/tenant ID — included in all SNS events
  1. KkiaPay sends a USSD push to the customer's phone. The customer approves on their phone.

  2. KkiaPay sends a webhook to the payments service. The service verifies it and creates the payment record.

  3. Auto-disburse fires immediately (fire-and-forget): the payments service calls POST /disbursements to send the money to businessPhone via MTN MoMo (primary) or Moov Africa (fallback).

  4. Two SNS events fire:

    • payment.completed — your SNS handler marks the invoice as paid
    • disbursement.completed (or disbursement.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 required

You can then manually trigger POST /disbursements with the business's phone.

What if auto-disburse fails?

The payment confirmation is not affectedpayment.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 amount for a full refund
  • The intent must have status: succeeded to 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.refunded SNS event is always published

Local Development

Option A — Run the Payments service locally

bash
# In the payments project
cp .env.example .env
# Fill in at minimum: NODE_ENV=development
npm run dev
# Listening on http://localhost:3002

Then in your app:

bash
PAYMENTS_SERVICE_URL=http://localhost:3002/api/v1 npm run dev

Option 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:

typescript
// 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.

ProductappId
Kaba (accounting)kaba
Eventsevents
Telemedicinetelemedicine
School Managementschool

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):

VariableDescription
PAYMENTS_SERVICE_URLBase URL of the Payments API (no trailing slash). Example: https://hfy53j9rjc.execute-api.ca-central-1.amazonaws.com/dev/api/v1
TKH_PAYMENTS_API_KEYYour 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):

VariableDescription
NEXT_PUBLIC_KKIAPAY_PUBLIC_KEYKkiaPay 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:

json
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

FilePurpose
projects/quickbooks/backend/src/domains/payments/services/PaymentsClient.tsFull HTTP client — sends TKH_PAYMENTS_API_KEY in X-API-Key header, passes businessPhone in metadata
projects/quickbooks/backend/src/infrastructure/handlers/payment-event.tsSNS Lambda handler — marks invoice paid, creates ledger entry, reads businessId from event
projects/quickbooks/backend/src/infrastructure/stacks/KabaApiStack.tsCDK wiring — Lambda + SNS subscription + env vars + filter policy
projects/quickbooks/backend/src/infrastructure/config/environments.tspaymentsServiceUrl, tkhPaymentsApiKey, paymentsSnsTopicArn per env

AWS Account IDs

AccountID
TKH Payments (service owner)497172038983
Kaba dev110044886269
Events dev307869868537

TKH Tech — Payments microservice