How split payments work in Nigerian marketplaces

When a buyer pays on a marketplace — a fashion platform, logistics aggregator, or multi-vendor e-commerce app — the platform collects the full transaction amount through one payment gateway call and the gateway distributes it to the relevant parties immediately at settlement. A typical split looks like this: 85% goes to the vendor's sub-account, 10% goes to the platform's operating account, and 5% goes to the logistics partner's sub-account.

Paystack implements this through the split object inside the charge initialization payload. You define the type (flat amount or percentage), list each subaccount code with its corresponding share, and Paystack handles the disbursement. Flutterwave exposes the same capability through a subaccounts array in the payment link or inline checkout payload. Both implementations work at the API layer during charge initialization — before the buyer even enters their card details.

This is legitimate, well-documented functionality. The gateway needs to know where the money goes before it collects it. The design problem is that many teams assemble this split object using data that comes from — or passes through — the client.

Where the vulnerability lives

Many Nigerian marketplace implementations follow a pattern like this: the frontend calls a backend endpoint to initiate checkout, the backend retrieves order data, then passes all or part of the order object — including vendor sub-account codes and fee shares — back to the client as part of a checkout configuration object. The client then uses that configuration to call Paystack's inline JS or redirect to the payment page.

In more fragile implementations, the split object is assembled entirely on the frontend from state stored in localStorage, a Redux store, or data embedded in HTML form fields. In either case, the critical assumption is that the data the client sends to the payment gateway matches what the backend originally computed. There is no server-side verification at the moment the charge is initiated.

The window of attack is the HTTP request between the client and the Paystack or Flutterwave charge initialization endpoint — or, in some architectures, the request from the client to the platform's own backend that then forwards the split to Paystack. If any part of the split configuration flows through or is readable by the client without a server-side integrity check, it can be modified.

The attack: Paystack split object manipulation

Here is the full attack sequence against a vulnerable Paystack marketplace integration:

  1. The attacker registers on the marketplace as a legitimate vendor. During onboarding, Paystack assigns them a real sub-account code: ACCT_attacker999.
  2. The attacker places an order — they are now simultaneously a vendor on the platform and a buyer placing an order against another vendor.
  3. At checkout, the attacker opens Burp Suite and intercepts the charge initialization request. This is the POST to the platform's /checkout/initiate endpoint or directly to Paystack's /transaction/initialize.
  4. The intercepted request body contains the split object. The attacker locates the platform's sub-account entry (ACCT_platform456) and replaces it with their own sub-account code.
  5. The modified request is forwarded. Paystack receives it, validates that all sub-account codes exist within the same integration, and accepts the transaction.
  6. The buyer completes payment. Settlement distributes as configured in the tampered payload: the platform's 10% commission lands in the attacker's sub-account instead.

The JSON modification is minimal and requires no advanced tooling:

// Original split object — as constructed by the legitimate backend
{
  "split": {
    "type": "flat",
    "subaccounts": [
      { "subaccount": "ACCT_vendor123",   "share": 85 },
      { "subaccount": "ACCT_platform456", "share": 10 },
      { "subaccount": "ACCT_logistics789","share": 5  }
    ]
  }
}

// Tampered split object — attacker replaces platform sub-account
{
  "split": {
    "type": "flat",
    "subaccounts": [
      { "subaccount": "ACCT_vendor123",   "share": 85 },
      { "subaccount": "ACCT_attacker999", "share": 10 },
      { "subaccount": "ACCT_logistics789","share": 5  }
    ]
  }
}

Paystack's role is to validate the payment mechanics — not your business logic. Both payloads are structurally valid. Both complete successfully. The difference is invisible in Paystack's dashboard unless you are actively comparing sub-account codes against your internal order records.

Variant attack: amount inflation via client-supplied totals

A related but distinct attack targets the amount field rather than the split destinations. If the backend calculates the transaction amount by reading a value submitted by the client — instead of computing it server-side from the order record — the attacker can inflate it.

The attacker intercepts the checkout request and modifies the amount field from, say, 1500000 (₦15,000 in kobo) to 2000000 (₦20,000). If the platform passes this amount to Paystack without cross-referencing the stored order total, the buyer is charged ₦20,000 for a ₦15,000 order. The vendor's flat-fee settlement is computed against the inflated total, the attacker vendor receives an elevated payout, and the platform's reconciliation logs show a completed transaction with no surface-level anomaly.

This attack is only viable when the attacker is also the vendor on the order, or when they control an account that receives a percentage-based share. It does not require access to another user's session — only the ability to intercept and modify outgoing HTTP requests, which any browser developer tools or Burp Suite makes trivial.

Business impact

Neither attack triggers an error. Both complete silently. The platform's webhook receives a charge.success event, the order is fulfilled, and the financial loss only surfaces during bank reconciliation — which many Nigerian fintech and marketplace teams run weekly or monthly, not in real time.

Consider the arithmetic. The average Nigerian marketplace transaction on fashion, electronics, or food delivery platforms runs between ₦15,000 and ₦200,000. A platform charging 10% commission loses between ₦1,500 and ₦20,000 per tampered transaction. An attacker running this across 10–15 orders per day — well within normal vendor activity thresholds — accumulates between ₦45,000 and ₦600,000 in stolen platform revenue weekly before any anomaly detection fires.

Beyond direct revenue loss, the downstream consequences compound:

The fix: server-side split construction with no client input

The fix has one non-negotiable rule: the split object is never accepted from the client and never assembled using client-supplied data. The backend constructs it entirely from records keyed to the server-side order ID. Here is the correct flow:

  1. Buyer submits checkout — the request body contains only the order ID and buyer authentication token. No amounts, no sub-account codes, no split configuration.
  2. Backend authenticates the request, retrieves the order by ID, verifies the authenticated user matches the order's buyer, and fetches vendor, platform, and logistics sub-account codes from the database.
  3. Backend constructs the split object server-side using only values from the database record — never values from the request body.
  4. Backend calls Paystack's /transaction/initialize directly from the server and returns only a checkout URL or authorization reference to the client.
  5. Client redirects to the Paystack-hosted payment page. The client never touches the split object at any point.
  6. On webhook receipt, backend verifies: does the amount in the webhook match the stored order total? Does the metadata.order_id match a pending order for this user? Only then is the order marked fulfilled.

Additionally: regenerate sub-account split references periodically and revoke old codes. If a split code is compromised or leaked, rotating it limits the exposure window. Paystack's split group feature can assist here — create and store split groups server-side, reference them by a server-held code only, and rotate them on a schedule.

Correct server-side split construction (Node.js)

The following shows the correct pattern. The split object is assembled exclusively from database values. The client receives a URL, nothing more.

// POST /checkout/initiate
// Request body: { orderId: string }
// Client sends nothing else.

import Paystack from 'paystack-node';
import { Order } from '../models/Order';

const paystack = new Paystack(process.env.PAYSTACK_SECRET_KEY);

export async function initiateCheckout(req, res) {
  const { orderId } = req.body;

  // 1. Fetch order from DB — never trust client for amounts or sub-accounts
  const order = await Order.findById(orderId).populate('vendor logistics');

  if (!order) {
    return res.status(404).json({ error: 'Order not found' });
  }

  // 2. Verify the authenticated user is the buyer on this order
  if (order.buyerId.toString() !== req.user.id) {
    return res.status(403).json({ error: 'Unauthorized' });
  }

  // 3. Verify order is in a state that allows payment
  if (order.status !== 'pending_payment') {
    return res.status(400).json({ error: 'Order is not payable' });
  }

  // 4. Construct split entirely from DB records — no client input used
  const split = {
    type: 'flat',
    subaccounts: [
      {
        subaccount: order.vendor.paystackSubaccountCode, // from DB
        share: order.vendorAmountKobo,                   // from DB
      },
      {
        subaccount: process.env.PLATFORM_SUBACCOUNT_CODE, // from env, never client
        share: order.platformFeeKobo,                      // from DB
      },
    ],
  }

  // Include logistics sub-account only when applicable
  if (order.logistics && order.logisticsFeeKobo > 0) {
    split.subaccounts.push({
      subaccount: order.logistics.paystackSubaccountCode,
      share: order.logisticsFeeKobo,
    });
  }

  // 5. Initialize charge server-side — total amount comes from DB
  const response = await paystack.transaction.initialize({
    amount: order.totalAmountKobo, // from DB — never from req.body
    email: req.user.email,
    reference: 'ORDER-' + order._id.toString() + '-' + Date.now().toString(),
    metadata: {
      order_id: order._id.toString(),
      buyer_id: req.user.id,
    },
    split,
  });

  // 6. Mark order as payment_initiated and store the reference
  order.paystackReference = response.data.reference;
  order.status = 'payment_initiated';
  await order.save();

  // 7. Return only the authorization URL — no split data, no amounts
  return res.json({ authorization_url: response.data.authorization_url });
}
// POST /webhooks/paystack
// Verify webhook signature first, then validate against DB

import crypto from 'crypto';
import { Order } from '../models/Order';

export async function paystackWebhook(req, res) {
  // 1. Verify the request is genuinely from Paystack
  const hash = crypto
    .createHmac('sha512', process.env.PAYSTACK_SECRET_KEY)
    .update(JSON.stringify(req.body))
    .digest('hex');

  if (hash !== req.headers['x-paystack-signature']) {
    return res.status(400).json({ error: 'Invalid signature' });
  }

  const event = req.body;

  if (event.event === 'charge.success') {
    const { reference, amount, metadata } = event.data;

    // 2. Fetch order by reference stored at initiation
    const order = await Order.findOne({ paystackReference: reference });

    if (!order) {
      return res.status(404).json({ error: 'Order not found for reference' });
    }

    // 3. Validate: amount in webhook must match server-side order total
    if (amount !== order.totalAmountKobo) {
      // Log for investigation — do NOT fulfill the order
      console.error('Amount mismatch: expected ' + order.totalAmountKobo + ', got ' + amount);
      return res.status(200).end(); // Acknowledge to Paystack but do not fulfill
    }

    // 4. Validate: metadata buyer must match stored buyer
    if (metadata.buyer_id !== order.buyerId.toString()) {
      console.error('Buyer mismatch on order ' + order._id);
      return res.status(200).end();
    }

    // 5. All checks pass — fulfill the order
    order.status = 'paid';
    await order.save();
  }

  return res.status(200).end();
}
Example finding

Hidden form field exposes full Paystack split configuration

During a security assessment of a Nigerian fashion marketplace, we found that the checkout flow embedded the complete Paystack split object — including all sub-account codes and fee shares — in a hidden HTML <input> field that was submitted alongside the payment form. Changing the platform's sub-account code in the field using browser developer tools before form submission redirected the platform's commission to an attacker-controlled sub-account. The attack required no special tools, no elevated access, and no knowledge beyond basic browser DevTools. Any registered vendor on the platform could execute it. The fix was to remove the split object from the frontend entirely and reconstruct it server-side from the order record at the moment of charge initialization. Fix priority: critical.

What automated scanners miss

Automated vulnerability scanners — DAST tools, API fuzzers, even purpose-built API security platforms — do not understand multi-party payment semantics. When they send a request to /checkout/initiate and receive a 200 OK with an authorization URL, they record a pass. They have no concept of what the split object inside that request should contain, which sub-account codes are legitimate for a given order, or whether the amount reflects a real order total. They see a structurally valid HTTP exchange and move on.

Only a reviewer who has tested marketplace payment integrations at the application layer — who understands how Paystack and Flutterwave split payment flows are assembled, where the trust boundary should sit, and what the gateway validates versus what it does not — will catch this class of vulnerability. Manual review with business logic awareness is the only reliable detection method.

Does your marketplace construct the split object on the client, or is every sub-account code and fee share locked down server-side with no client influence?

Get a security review

Frequently asked questions

Does Paystack validate sub-account ownership on their end?

Paystack validates that the sub-account codes exist and belong to the same Paystack integration. They do not validate whether the split logic is correct from your business perspective. That is your responsibility.

Can we use Paystack's split groups feature to mitigate this?

Split groups (pre-defined splits stored in Paystack) help by referencing a split by a fixed code. However, if the split_code itself is passed from the client, the same manipulation is possible. The split must be selected server-side.

How should we test this ourselves?

Use a Paystack test account, create two sub-accounts, initiate a checkout, intercept the request, modify the subaccount code in the split object, and observe whether Paystack accepts the modified split. If it completes, you are vulnerable.

Related reading

Blog: Webhook security for payment platforms · BOLA vulnerabilities in payment APIs

Services: API security testing · Payment gateway security