DEV Community

Cover image for Build a Digital Marketplace with Afriex, Fastify and Postgres, Part 2: Checkout and Payouts
Victory Lucky for Afriex

Posted on

Build a Digital Marketplace with Afriex, Fastify and Postgres, Part 2: Checkout and Payouts

At the end of Part 1, Tunde was stuck. He clicked Buy on Amara's ₦12,000 preset pack, an order was created but no money moved, because nothing in Part 1 knows how to move money.

This part fixes that, in both directions:

  • Checkout: collect Tunde's ₦12,000 through Afriex: by card, bank transfer, or mobile money, whichever suits him.
  • Payouts: track what the platform owes Amara, and send it to her bank account when she asks.

The full source is on GitHub if you want to follow along.

What you'll have at the end

  • Tunde can pay for an order through the Afriex checkout page.
  • A confirmed payment completes the order and credits Amara's balance, minus the platform fee.
  • Amara can register her bank account, and the platform verifies it actually belongs to her before any money moves.
  • Amara can withdraw her balance on demand, or get paid out on a schedule.
  • A failed or ambiguous payout never silently loses money: it either refunds her balance or waits for review.

Before you start

You need Part 1's code running, plus an Afriex business account with API keys in your .env (AFRIEX_API_KEY and AFRIEX_WEBHOOK_PUBLIC_KEY). The withdrawal queue in this part runs on Redis; if you started the repo's docker compose up -d in Part 1, Redis is already running. The disbursement worker is its own process, started with pnpm worker:dev alongside pnpm dev. Everything below assumes the schema and module layout from Part 1.


Let's look at the whole flow first

A diagram showing how the payment flows

Read it top to bottom and it's Tunde's money coming in, then Amara's money going out. Two structural choices in this diagram shape everything that follows:

  1. One payment confirmation triggers two separate writes. When Tunde's payment clears, the order gets completed (so he gets his download) and a sale gets recorded (so Amara gets credited).
  2. A sale credits a balance; it never sends money. Recording that the platform owes Amara ₦10,800 and actually transferring ₦10,800 to her bank are separate acts, possibly days apart. Because they're separate, you can offer instant payouts, weekly sweeps, or minimum-balance thresholds.

Let's create the checkout session

Call the Afriex create session endpoint to create a checkout session, and create an order row pointing at it.

// modules/orders/orders.service.ts
async createCheckoutSession(input: {
  productId: string;
  customerEmail: string;
  customerName: string;
  successUrl: string;
  cancelUrl: string;
}): Promise<{ sessionId: string; sessionUrl: string; provider: string }> {
  const product = await productsService.getPublishedById(input.productId);
  const provider = getPaymentProvider(resolveCheckoutProvider());
  const creator = await creatorsRepository.findById(product.creatorId);

  const result = await provider.createCheckoutSession({
    amount: product.price,
    currency: product.currency,
    customerEmail: input.customerEmail,
    customerName: input.customerName,
    successUrl: input.successUrl,
    cancelUrl: input.cancelUrl,
    metadata: {
      productId: product.id,
      creatorId: product.creatorId,
      creatorUserId: creator?.userId ?? '',
    },
  });

  await ordersRepository.create({
    productId: product.id,
    creatorId: product.creatorId,
    customerEmail: input.customerEmail,
    customerName: input.customerName,
    amount: product.price,
    currency: product.currency,
    paymentSessionId: result.sessionId,
  });

  return { sessionId: result.sessionId, sessionUrl: result.sessionUrl, provider: resolveCheckoutProvider() };
}
Enter fullscreen mode Exit fullscreen mode

The API responds with sessionUrl, the frontend redirects Tunde there, and he pays on Afriex's page. Here's the provider side of that call:

// infra/payment/providers/afriex-checkout.ts
async createCheckoutSession(params: CreateCheckoutSessionParams): Promise<CheckoutSessionResponse> {
  const merchantReference = `co-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;

  const session = await afriex.checkout.createSession({
    amount: toMinorUnits(params.amount, params.currency),
    currency: params.currency,
    merchantReference,
    redirectUrl: params.successUrl,
    customer: {
      name: params.customerName,
      email: params.customerEmail,
      phone: params.customerPhone ?? params.metadata.customerPhone ?? '+2340000000000',
      countryCode: params.metadata.countryCode ?? 'NG',
    },
    channels: ['CARD', 'VIRTUAL_BANK_ACCOUNT', 'MOBILE_MONEY'],
    metadata: params.metadata,
  });

  return { sessionId: merchantReference, sessionUrl: session.checkoutUrl, provider: this.name };
}
Enter fullscreen mode Exit fullscreen mode

Three details to slow down on:

toMinorUnits converts the amount before it leaves your server. Like most payment APIs, Afriex takes amounts in the currency's smallest unit (kobo for naira, cents for dollars). So Amara's ₦12,000.00 product is sent as 1200000.

channels decides what Tunde sees. The same call serves a card buyer in the US and a mobile-money buyer in Kenya. You list which channels the platform accepts; Afriex shows Tunde whichever of them work in his country.

merchantReference. When Afriex later reports "this session was paid," this reference is how you find which order it means.

One webhook, two writes

The only way to know when Tunde pays is when Afriex sends you a webhook event, CHECKOUT_SESSION.CREATED. Your server verifies the request signature to prove it came from Afriex, then acts on it.

One handler acts on the payment confirmation, and it makes both writes from the diagram:

// modules/sales/sales.controller.ts
if (provider.isCheckoutCompletedEvent(event)) {
  const metadata = provider.getMetadata(event);
  const sessionId = provider.getTransactionId(event);

  if (sessionId) {
    await ordersService.completeOrder(sessionId);
  }

  const creator = await creatorsService.getById(metadata.creatorId);
  const amount = provider.getAmount(event);
  const currency = provider.getCurrency(event);

  if (creator && amount && currency) {
    await salesService.recordConfirmedPayment({
      paymentIntentId: sessionId ?? 'unknown',
      creatorUserId: creator.userId,
      grossAmount: amount,
      currency: currency as 'USD' | 'NGN' | 'GHS' | 'KES',
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

completeOrder updates the orders.status to COMPLETED and issues the download token. recordConfirmedPayment is where this article's tables begin:

// modules/sales/sales.service.ts
async recordConfirmedPayment(event: ConfirmedPaymentEvent): Promise<Sale> {
  const existing = await salesRepository.findByPaymentIntentId(event.paymentIntentId);
  if (existing) return existing; // webhook replay — already processed

  const creator = await creatorsService.getByUserId(event.creatorUserId);
  const sale = await salesRepository.create({
    creatorId: creator.id,
    paymentIntentId: event.paymentIntentId,
    grossAmount: event.grossAmount,
    currency: event.currency,
    status: 'PAID',
  });

  await earningsService.processSale(sale);
  return sale;
}
Enter fullscreen mode Exit fullscreen mode

That existing check exists because webhooks get delivered more than once. If your endpoint is slow to respond, or the connection drops after you processed the event but before Afriex has received your 200 OK response, it retries. Normal behavior, not a bug. Handle the same event twice naively and Amara gets credited ₦10,800 twice for one sale.

The check at the top is the fast path, but it's not the real protection. Two replays arriving at the same moment can both pass it before either inserts. The real protection is paymentIntentId which carries a UNIQUE constraint, so the second insert fails at the database no matter how the requests race.


Sale → earning → balance

processSale splits the money and credits Amara. Tunde paid ₦12,000, the platform fee is 10% (which is ₦1,200), then Amara gets ₦10,800.

// modules/earnings/earnings.service.ts
async processSale(sale: Sale): Promise<Earning> {
  const fee = computeFee(sale.grossAmount, env.PLATFORM_FEE_PERCENT);

  const earning = await earningsRepository.create({
    creatorId: sale.creatorId,
    saleId: sale.id,
    grossAmount: fee.grossAmount,
    platformFeeAmount: fee.platformFeeAmount,
    amount: fee.netAmount,
    currency: sale.currency,
    status: 'CONFIRMED',
  });

  // Credit the creator's balance in the sale currency — not the payout currency.
  await creatorsRepository.incrementBalance(sale.creatorId, fee.netAmount, sale.currency);

  // Gross settles into that currency's pool account; the fee stays platform revenue.
  const poolAccount = await poolAccountsService.getByCurrencyOrThrow(sale.currency);
  await poolAccountsService.settleSaleIntoPool(poolAccount.id, fee.grossAmount);

  return earning;
}
Enter fullscreen mode Exit fullscreen mode

Notice there is no Afriex call in this function. It's pure bookkeeping, and it writes to two different kinds of account:

  • Amara's balance goes up by ₦10,800. This is a liability, money the platform owes her.
  • The NGN pool account goes up by ₦12,000. A pool account tracks the money the platform is actually holding in a given currency. The ₦1,200 gap between what the pool holds and what creators are owed is the platform's revenue.

Balances and pools are both tracked per currency. An NGN sale credits an NGN balance and settles into the NGN pool; a USD sale uses its own pool. Keeping the currencies in separate books means a report can always answer "how much NGN do we hold vs. owe?" without any exchange-rate assumptions. It's also why the withdrawal code below refuses to pay an NGN balance out to a USD bank account.


Onboarding Amara for payout

Before Amara can withdraw anything, she registers her bank account. This is the one step where the platform asks Afriex to confirm something is real rather than just storing what was typed:

// infra/afriex/afriex-client.ts
async registerRecipient(params: RegisterRecipientParams): Promise<RegisterRecipientResult> {
  const customer = await afriex.customers.create({
    fullName: params.fullName,
    email: params.email,
    phone: params.phone,
    countryCode: params.countryCode,
  });

  // A payment method is only VERIFIED when Afriex's own account-resolution
  // endpoint returns an account-holder name matching the creator's name on
  // file — creating the payment method alone proves nothing about whether
  // the account belongs to this creator.
  let verified = false;
  let resolvedAccountName: string | undefined;
  try {
    const resolved = await afriex.paymentMethods.resolveAccount({
      channel: 'BANK_ACCOUNT',
      accountNumber: params.accountNumber,
      institutionCode: params.bankCode,
      countryCode: params.countryCode,
    });
    resolvedAccountName = resolved.recipientName;
    verified = resolvedAccountName ? namesMatch(resolvedAccountName, params.fullName) : false;
  } catch {
    // Resolution failed — leave the method PENDING rather than guessing.
  }

  const paymentMethod = await afriex.paymentMethods.create({
    channel: 'BANK_ACCOUNT',
    customerId: customer.customerId,
    accountName: resolvedAccountName ?? params.fullName,
    accountNumber: params.accountNumber,
    countryCode: params.countryCode,
    institution: { institutionCode: params.bankCode, institutionName: params.bankName },
  });

  return {
    afriexCustomerId: customer.customerId,
    afriexPaymentMethodId: paymentMethod.paymentMethodId,
    verified,
  };
}
Enter fullscreen mode Exit fullscreen mode

Account resolution is the step worth naming: when Amara provides her account number and bank code (gotten from the Afriex institutions list), Afriex verifies the account and returns the registered holder's name. Let's say "AMARA JOHNSON" is the name returned; namesMatch compares that against the name on her profile, and only a match marks the method VERIFIED.

The failure case is the reason this exists. If Amara fat-fingers one digit, the resolved name comes back as some stranger's, the match fails, and the method stays PENDING, instead of her first ₦10,800 payout landing in that stranger's account, unrecoverable. Only VERIFIED methods make a creator payoutEligible, and only eligible creators enter the scheduled sweep.


Requesting and disbursing a withdrawal

Amara can withdraw on demand, or a scheduled sweep can pay out every eligible creator's balance. Both paths converge on the same queue:

// modules/withdrawals/withdrawals.service.ts
async requestOnDemandWithdrawal(creatorId: string, amount?: string, currency?: CurrencyCode) {
  const creator = await creatorsRepository.findById(creatorId);
  this.assertCooldownElapsed(creator);

  const withdrawCurrency = currency ?? creator.payoutCurrency;
  const payoutMethod = await payoutMethodsService.getVerifiedMethodOrThrow(creatorId);
  if (payoutMethod.currency !== withdrawCurrency) {
    throw new ValidationError(`Your verified payout method is in ${payoutMethod.currency}.`);
  }

  const poolAccount = await poolAccountsService.getByCurrencyOrThrow(withdrawCurrency);
  return this.createAndQueue({ creator, payoutMethodId: payoutMethod.id, poolAccountId: poolAccount.id, amount: amount || available, currency: withdrawCurrency, trigger: 'ON_DEMAND' });
}
Enter fullscreen mode Exit fullscreen mode

Queueing instead of calling Afriex inline matters because the transfer call can be slow, can fail, and needs retries, none of which should hold Amara's payout request open. The queue here is BullMQ, a Redis-backed job queue; a worker picks the job up and makes the actual call.

The worker, and the one distinction that protects the money

// infra/queue/worker.ts
try {
  const transfer = await afriexClient.createTransfer({
    customerId: payoutMethod.afriexCustomerId,
    paymentMethodId: payoutMethod.afriexPaymentMethodId,
    amount: withdrawal.amount,
    currency: withdrawal.currency,
    idempotencyKey: withdrawal.id,
  });

  await withdrawalsRepository.markProcessing(withdrawal.id, transfer.afriexTransactionId);
  await poolAccountsRepository.decrementBalance(poolAccount.id, withdrawal.amount);

  if (transfer.status === 'COMPLETED') {
    await withdrawalsRepository.markPaid(withdrawal.id);
  }
} catch (err) {
  const isFinalAttempt = job.attemptsMade + 1 >= (job.opts.attempts ?? 1);
  if (!isFinalAttempt) throw err; // let BullMQ retry

  if (isDefiniteRejection(err)) {
    // Afriex explicitly rejected the request (4xx, not a rate limit) —
    // safe to fail the withdrawal and credit the balance back.
    await failWithdrawal(withdrawal, err.message);
  } else {
    // Timeout, 5xx, or network error: Afriex may have already processed
    // the transfer server-side. Crediting the balance back here could
    // double-pay the creator if it did go through, so this is parked as
    // UNKNOWN for manual reconciliation instead of guessed at.
    await withdrawalsRepository.markUnknown(withdrawal.id, err.message);
  }
}
Enter fullscreen mode Exit fullscreen mode

When the transfer call fails on its final retry, do we know for certain the money didn't move?

What happened What it means What the worker does
4xx rejection (bad account, insufficient pool) Afriex definitely did not send money Mark FAILED, credit Amara's balance back
Timeout, 5xx, network error Afriex might have sent it before things broke Mark UNKNOWN, touch nothing, flag for a human

The UNKNOWN state is the careful choice. Suppose the request timed out but Afriex actually completed the transfer. If the worker "helpfully" credited ₦10,800 back to Amara's balance, she'd have the money in her bank and the balance to withdraw it again: the platform pays twice. Parking the withdrawal for reconciliation by a human (or a later job) checking Afriex's transaction record against yours costs a support ticket. Guessing costs real money.

Closing the loop

Bank transfers aren't always instant, so a transfer that left in PROCESSING state gets its final answer by webhook, the same pattern as checkout:

// infra/afriex/afriex-webhook.router.ts
const { transactionId, status, meta } = payload.data;

// The idempotency key exists in our DB before the transfer call was even
// made, so looking withdrawals up by it — instead of by afriexTransactionId,
// which is only written after the call returns — means the webhook can
// never arrive "too early" to find its withdrawal.
const idempotencyKey = meta?.idempotencyKey;
const withdrawal = isUuid(idempotencyKey)
  ? await withdrawalsRepository.findById(idempotencyKey)
  : await withdrawalsRepository.findByAfriexTransactionId(transactionId);

if (status === 'COMPLETED' || status === 'SUCCESS') {
  await withdrawalsRepository.markPaid(withdrawal.id);
} else if (status === 'FAILED' || status === 'CANCELLED' || status === 'REJECTED') {
  await withdrawalsRepository.markFailed(withdrawal.id, `Afriex reported ${status}`);
  await creatorsRepository.incrementBalance(withdrawal.creatorId, withdrawal.amount, withdrawal.currency);
  await poolAccountsRepository.incrementBalance(withdrawal.poolAccountId, withdrawal.amount);
}
Enter fullscreen mode Exit fullscreen mode

The idempotency key sent to Afriex is the withdrawal's own database ID, chosen before the transfer call, not after. Because the ID existed in Postgres first, the confirmation webhook can always find its withdrawal by that key, even if it somehow arrives before the transfer call's response was recorded. Sending a key also means a retried createTransfer with the same key can't produce a second transfer on Afriex's side, the same double-send protection Part 1's unique constraint gave orders, now on the provider's side of the wire.

Same handler shape as checkout: verify the signature, find the row this event is about, move it to a terminal state, and, on failure, put the money back where it came from, in both books.


Where we are

  • ✅ Tunde pays through a hosted checkout: card, bank transfer, or mobile money
  • ✅ One webhook completes his order and records Amara's sale
  • ✅ A 10% fee split leaves ₦10,800 on Amara's NGN balance and ₦12,000 in the NGN pool
  • ✅ Amara's bank account is verified by name before it can receive money
  • ✅ Withdrawals retry, refund on definite failure, and park as UNKNOWN when the outcome is uncertain

The Afriex Business API's multi-currency payout support gives you the opportunity to send and receive payments globally, whether the goal is personal or business, small team or large enterprise. The docs cover everything this series didn't touch.

The full source for this project, including the withdrawal cooldown, minimum-amount logic, and the scheduled sweep cron, is on GitHub. Questions or feedback? Drop them in the comments or reach out on X @codewithveek.

Top comments (0)