DEV Community

Cover image for How to Collect Mobile Money Payments for Your SaaS Through Afriex
Victory Lucky for Afriex

Posted on

How to Collect Mobile Money Payments for Your SaaS Through Afriex

Building a product is hard enough. Getting paid for it shouldn't be. In this post I'll show you how to accept mobile money payments from Kenya, Uganda and Tanzania in 5 steps.

Cards barely work for recurring billing in these markets. Mobile money — a wallet held against a phone number — is how people actually pay.

Mobile money collection is live in Kenya (KES), Uganda (UGX), Tanzania (TZS), Côte d'Ivoire and Benin (XOF), Cameroon (XAF) and Ethiopia (ETB). Ghana, Rwanda and a few others are payout-only for now, so check the supported rails table before you support a market.

The flow:

list providers -> verify the number -> save it -> charge it -> track it
Enter fullscreen mode Exit fullscreen mode

Setup

npm install @afriex/sdk
Enter fullscreen mode Exit fullscreen mode
// src/afriex/client.ts
import { AfriexSDK } from "@afriex/sdk";

export const afriex = new AfriexSDK({
  apiKey: process.env.AFRIEX_API_KEY!,
  environment: process.env.NODE_ENV === "production" ? "production" : "staging",
  webhookPublicKey: process.env.AFRIEX_WEBHOOK_PUBLIC_KEY!,
  retryConfig: { maxRetries: 3, retryDelay: 1000 },
});
Enter fullscreen mode Exit fullscreen mode

Retries are off by default — turn them on. Grab both keys from Developer -> API keys and Developer -> Webhooks at business.afriex.com. Staging and production webhook keys differ.

Your customer must exist in Afriex before they can have a payment method:

// src/afriex/customers.ts
export async function createAfriexCustomer(user: User): Promise<string> {
  const customer = await afriex.customers.create({
    fullName: user.fullName,
    email: user.email,
    phone: user.phone,   // E.164, must match countryCode
    countryCode: user.countryCode, // "KE" | "UG" | "TZ"
  });

  return customer.customerId; // store this on your user row
}
Enter fullscreen mode Exit fullscreen mode

If the email or phone already exists you get a 400 back, but details.data.customerId carries the existing ID — adopt it instead of doing a lookup.


Step 1 — List the providers

Don't hardcode providers. Fetch them per country.

// src/afriex/institutions.ts
export interface MobileMoneyProvider {
  institutionCode: string;
  institutionName: string;
  institutionId?: string;
}

export async function listMobileMoneyProviders(
  countryCode: string,
): Promise<MobileMoneyProvider[]> {
  return afriex.paymentMethods.getInstitutions({
    channel: "MOBILE_MONEY",
    countryCode,
  });
}
Enter fullscreen mode Exit fullscreen mode

Live responses:

// KE
[{ "institutionCode": "SAFARICOM", "institutionName": "SAFARICOM" },
 { "institutionCode": "AIRTEL",    "institutionName": "AIRTEL" }]

// UG
[{ "institutionCode": "MTN",    "institutionName": "MTN" },
 { "institutionCode": "AIRTEL", "institutionName": "AIRTEL" }]
Enter fullscreen mode Exit fullscreen mode

Send institutionCode to the API. Don't show institutionName to users — SAFARICOM means nothing to someone who calls it M-Pesa. Map it yourself:

// src/afriex/provider-labels.ts
export const PROVIDER_LABELS: Record<string, string> = {
  SAFARICOM: "M-Pesa",
  MTN: "MTN MoMo",
  AIRTEL: "Airtel Money",
};
Enter fullscreen mode Exit fullscreen mode

Step 2 — Verify the number

Check who owns a number before you save it. This is the difference between a typo caught in the UI and a failed charge.

// src/afriex/resolve-account.ts
export async function resolveMobileMoneyAccount(options: {
  phoneNumber: string;
  countryCode: string;
  institutionCode: string;
}): Promise<{ recipientName: string }> {
  const { phoneNumber, countryCode, institutionCode } = options;

  return afriex.paymentMethods.resolveAccount({
    channel: "MOBILE_MONEY",
    accountNumber: phoneNumber,
    institutionCode,
    countryCode,
  });
}
Enter fullscreen mode Exit fullscreen mode

Returns { recipientName, institutionName, institutionCode }. Show recipientName and make the user confirm before continuing.

Staging returns canned values here (you'll see John Doe), so build the confirmation UI but test it against production before trusting the name.


Step 3 — Save the payment method

// src/afriex/payment-methods.ts
export async function saveMobileMoneyMethod(options: {
  customerId: string;
  accountName: string;
  phoneNumber: string;
  countryCode: string;
  institutionCode: string;
}): Promise<string> {
  const {
    customerId, accountName, phoneNumber, countryCode, institutionCode,
  } = options;

  const paymentMethod = await afriex.paymentMethods.create({
    channel: "MOBILE_MONEY",
    customerId,
    accountName,
    accountNumber: phoneNumber,
    countryCode,
    institution: { institutionCode, institutionName: institutionCode },
    type: "DEPOSIT", // required — see below
  });

  return paymentMethod.paymentMethodId;
}
Enter fullscreen mode Exit fullscreen mode

Don't skip type. It sets the method's capability, and it defaults to WITHDRAW — the payout direction. Omit it and you get a method you can send money to, not collect from:

// with type: "DEPOSIT"     -> "capabilities": ["DEPOSIT"]
// with type omitted        -> "capabilities": ["WITHDRAW"]
Enter fullscreen mode Exit fullscreen mode

Your charge in step 4 will fail against a WITHDRAW-only method, and the error won't obviously point back here.

Also worth knowing: currency is derived from country and channel — don't send it. And the API stores accountNumber with a + prefix even though it accepts it without one, so normalise phone numbers on the way in rather than round-tripping the stored value.

Store paymentMethodId against your subscription. You'll pass it as sourceId on every charge.


Step 4 — Charge it

Collecting is a DEPOSIT with the customer's wallet as sourceId. For a deposit, destinationAmount is required and sourceAmount is optional — the reverse of the withdraw rules.

The simplest correct setup collects in the customer's own currency, so no conversion happens at charge time:

// src/afriex/collect.ts
import { ulid } from "ulid";

export async function chargeSubscription(options: {
  customerId: string;
  paymentMethodId: string;
  subscriptionId: string;
  localAmount: string;   // e.g. "3750.00"
  localCurrency: string; // "KES" | "UGX" | "TZS"
}) {
  const {
    customerId, paymentMethodId, subscriptionId, localAmount, localCurrency,
  } = options;

  const reference = `sub_${subscriptionId}_${Date.now()}`;

  const transaction = await afriex.transactions.create({
    type: "DEPOSIT",
    customerId,
    sourceId: paymentMethodId,
    sourceCurrency: localCurrency,
    destinationCurrency: localCurrency, // same currency — no FX on collection
    destinationAmount: localAmount,     // required for DEPOSIT
    meta: {
      idempotencyKey: ulid(),
      reference,
      narration: "Monthly subscription",
    },
  });

  return { transaction, reference };
}
Enter fullscreen mode Exit fullscreen mode

Persist reference on the subscription row before you await. If the call times out you still know what to look for — and reference is what you'll match the webhook on.

Amounts accept a number or a numeric string, and come back as strings. Keep money as bigint minor units internally and format only at this boundary — the balance endpoint hands back values like 51291590.19999999, and floats will burn you.

The response also carries rate and fee, both worth logging.

Collect in local currency, convert on your own schedule

Notice that destinationCurrency above is KES, not USD. That's deliberate.

You publish "KES 3,750/month" and the customer agrees to that number. If you set destinationCurrency: "USD", you're doing an FX conversion inside every single charge, and one of the two amounts has to absorb the rate movement. On mobile money the charged amount appears in the OTP prompt on the customer's phone, so a figure that drifts away from your pricing page is an abandonment risk, not just an accounting quirk.

Collecting KES into your KES wallet means no conversion at charge time. The customer pays exactly what you quoted, every cycle. You then convert when you choose, in whatever size you choose, with a SWAP:

// src/jobs/settle-to-usd.ts
await afriex.transactions.create({
  type: "SWAP",
  sourceCurrency: "KES",
  sourceAmount: accumulatedKes, // exactly one of source/destination
  destinationCurrency: "USD",
  meta: {
    idempotencyKey: ulid(),
    reference: `settlement-${settlementRunId}`,
  },
});
Enter fullscreen mode Exit fullscreen mode

A SWAP takes exactly one of sourceAmount or destinationAmount — the API computes the other side at the live rate. Sending both is rejected with Only one of source amount or destination amount can be provided. customerId is omitted, so it runs against your business wallet.

This also means fewer, larger conversions instead of one per subscriber, which is usually the cheaper side of the spread.

If you do convert at charge time

If you'd rather land USD directly, set destinationCurrency: "USD" and send destinationAmount as the USD figure — it's required for a deposit either way. sourceAmount is optional, and if you send both, destinationAmount wins by default. shouldPreferSourceAmount: true flips that so the source side drives, but the docs frame that flag around payouts, so test it on a deposit before relying on it. Either way, read the realized rate and the actual amounts off the response rather than assuming you got what you asked for.

The OTP step

Mobile money deposits often need a one-time password. If the response comes back CUSTOMER_ACTION_REQUIRED with meta.otpRequired: true, collect the OTP from the customer and submit it:

// src/afriex/authorize-deposit.ts
export async function authorizeDeposit(options: {
  transactionId: string;
  otp: string;
}) {
  const { transactionId, otp } = options;

  return afriex.transactions.authorize(transactionId, {
    type: "OTP",
    otp,
  });
}
Enter fullscreen mode Exit fullscreen mode

Skip this and the deposit sits unfinished forever. Handle it in your UI:

if (transaction.status === "CUSTOMER_ACTION_REQUIRED" && transaction.meta.otpRequired) {
  return { needsOtp: true, transactionId: transaction.transactionId };
}
Enter fullscreen mode Exit fullscreen mode

Step 5 — Track the payment

The webhook is the source of truth. Signatures are RSA-SHA256, base64, computed over the raw body — so you need the raw bytes, not the parsed object:

// src/plugins/raw-body.ts
import fastifyPlugin from "fastify-plugin";

export default fastifyPlugin(async (fastify) => {
  fastify.addContentTypeParser(
    "application/json",
    { parseAs: "string" },
    (request, body: string, done) => {
      request.rawBody = body;
      try {
        done(null, JSON.parse(body));
      } catch (error) {
        done(error as Error, undefined);
      }
    },
  );
});
Enter fullscreen mode Exit fullscreen mode

verifyAndParse is synchronous and takes positional arguments:

// src/routes/webhooks/afriex.ts
export async function afriexWebhookRoute(fastify: FastifyInstance) {
  fastify.post("/webhooks/afriex", async (request, reply) => {
    const signature = request.headers["x-webhook-signature"];

    if (typeof signature !== "string" || !request.rawBody) {
      return reply.code(400).send({ error: "missing_signature" });
    }

    try {
      const event = afriex.webhooks.verifyAndParse(request.rawBody, signature);

      if (event.event === "TRANSACTION.UPDATED") {
        await applyTransactionEvent(event.data);
      }

      return reply.code(200).send({ received: true });
    } catch {
      return reply.code(400).send({ error: "invalid_signature" });
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

Return 200 fast. Afriex retries failed deliveries up to 12 times with exponential backoff starting at 30 seconds, so a slow handler becomes a duplicate event.

There's no event ID in the payload, so key idempotency on the transaction and its status:

// src/subscriptions/apply-transaction-event.ts
const TERMINAL = ["SUCCESS", "FAILED", "CANCELLED", "REJECTED"];

export async function applyTransactionEvent(
  data: AfriexTransactionEventData,
): Promise<void> {
  const reference = data.merchantReference ?? data.meta?.reference;
  if (!reference || data.type !== "DEPOSIT") return;

  await database.transaction(async (trx) => {
    const inserted = await trx
      .insert(processedEvents)
      .values({ transactionId: data.transactionId, status: data.status })
      .onConflictDoNothing()
      .returning({ transactionId: processedEvents.transactionId });

    if (inserted.length === 0) return; // replay

    const subscription = await findByReference(trx, reference);
    if (!subscription) return;

    if (data.status === "SUCCESS") {
      await activateSubscription(trx, subscription.id);
    } else if (TERMINAL.includes(data.status)) {
      await markPaymentFailed(trx, subscription.id, data.meta?.failureReason);
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

The composite unique index on (transactionId, status) is what makes this idempotent — not an if, which two workers will race through together.

On failure, meta.failureReason carries a stable AFX_* code, a customer-safe message, and a retryable boolean. Branch on code, never the message:

if (failureReason?.retryable) {
  await scheduleRetry(subscription.id);
}
Enter fullscreen mode Exit fullscreen mode

Codes include AFX_VELOCITY_LIMIT_EXCEEDED, AFX_AMOUNT_LIMIT_EXCEEDED, AFX_COMPLIANCE_REJECTED and AFX_INVALID_RECIPIENT. The set grows, but existing values don't change meaning.


Testing it

Sandbox settles transactions automatically in a minute or two, and you steer the outcome through meta.reference:

Put this in meta.reference What happens
anything containing fail settles FAILED
SIMULATE_OTP returns CUSTOMER_ACTION_REQUIRED
SIMULATE_NO_OTP completes with no OTP step
anything else settles SUCCESS

The sandbox OTP is 123456. Any other value is rejected, so you can test the wrong-OTP path too.

You can also fire a real signed webhook at your endpoint without generating the activity:

await afriex.webhooks.triggerTestWebhook({
  event: "TRANSACTION.UPDATED",
  entityId: transactionId,
});
Enter fullscreen mode Exit fullscreen mode

Gotchas

  • Staging and production webhook public keys are different.
  • Pagination is zero-indexed — the first page is page: 0.
  • merchantReference on the webhook mirrors meta.reference from create.
  • Customer phone must match countryCode, or you get PHONE_COUNTRY_MISMATCH.
  • Surface details.friendlyMessage to users; branch on code.

That's it

Five calls: list providers, resolve the number, save the method, create a DEPOSIT, handle the webhook. Plus the OTP step, which is the one most integrations forget.

Top comments (0)