Bonaventure OgetoBy Bonaventure Ogeto|

Building a Two-Sided Marketplace on Paystack

Build a two-sided marketplace on Paystack by creating a subaccount for each seller during onboarding, passing the seller's subaccount code when initializing buyer transactions, and handling the charge.success webhook to confirm the split. Paystack settles each seller directly. Your commission is the residual amount not allocated to the subaccount.

Marketplace Architecture Overview

A two-sided marketplace connects buyers and sellers through your platform. The platform's job is to facilitate the transaction: the buyer pays, the seller delivers, and the platform takes a cut for making the connection. On Paystack, this maps cleanly to split payments.

The data model has three core entities:

  • Sellers: Each seller has a profile in your database and a corresponding Paystack subaccount. The subaccount stores their bank details and default commission rate.
  • Products/listings: Items listed by sellers. Each product belongs to a seller.
  • Orders: Created when a buyer purchases. Each order references the seller, the product(s), and the Paystack transaction.

The payment flow has four steps: buyer clicks "pay," your backend initializes a split transaction with the seller's subaccount code, the buyer completes payment through Paystack, and your webhook handler confirms the split and updates the order status. Paystack handles settlement to both parties automatically.

This architecture eliminates the need for a payout system on your side. You do not collect all the money and transfer the seller's share later. Paystack divides the money at the infrastructure level and settles each party directly. Your platform never holds the seller's funds.

Seller Onboarding: From Signup to Subaccount

When a seller signs up on your platform, you need to collect their bank details and create a Paystack subaccount. This is the most important step in the marketplace setup because an error here means the seller cannot receive payments.

// Complete seller onboarding flow
async function onboardSeller(sellerData) {
  // Step 1: Validate the bank details
  var resolveResponse = await fetch(
    'https://api.paystack.co/bank/resolve?account_number=' +
      sellerData.accountNumber +
      '&bank_code=' +
      sellerData.bankCode,
    {
      headers: {
        Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
      },
    }
  );

  var resolved = await resolveResponse.json();

  if (!resolved.status) {
    return {
      success: false,
      error: 'Could not verify bank details. Please check and try again.',
    };
  }

  // Step 2: Create the subaccount
  var subaccountResponse = await fetch('https://api.paystack.co/subaccount', {
    method: 'POST',
    headers: {
      Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      business_name: sellerData.shopName,
      settlement_bank: sellerData.bankCode,
      account_number: sellerData.accountNumber,
      percentage_charge: 80,
      description: 'Seller on ' + process.env.PLATFORM_NAME,
      primary_contact_email: sellerData.email,
      metadata: JSON.stringify({ seller_id: sellerData.id }),
    }),
  });

  var subaccountData = await subaccountResponse.json();

  if (!subaccountData.status) {
    return {
      success: false,
      error: 'Could not create payment account: ' + subaccountData.message,
    };
  }

  // Step 3: Store the subaccount code in your database
  await db.sellers.update(sellerData.id, {
    paystackSubaccountCode: subaccountData.data.subaccount_code,
    bankVerifiedName: resolved.data.account_name,
    onboardingComplete: true,
  });

  return { success: true, accountName: resolved.data.account_name };
}

Show the resolved account name to the seller before creating the subaccount. "Your bank account is registered to Mama Njeri Kitchen. Is this correct?" This prevents the common mistake where a seller enters the wrong account number and only discovers it when their first settlement goes to someone else's bank account.

Checkout: Initializing the Split Transaction

When a buyer purchases from a seller, your backend looks up the seller's subaccount code and passes it to Paystack when initializing the transaction.

// Checkout endpoint: create order and initialize split payment
async function checkout(buyerEmail, productId, quantity) {
  var product = await db.products.findById(productId);
  var seller = await db.sellers.findById(product.sellerId);

  if (!seller.paystackSubaccountCode) {
    throw new Error('Seller has not completed payment onboarding');
  }

  // Create the order in your database
  var order = await db.orders.create({
    buyerEmail: buyerEmail,
    sellerId: seller.id,
    productId: product.id,
    quantity: quantity,
    totalAmount: product.priceInKobo * quantity,
    status: 'pending_payment',
  });

  // Initialize the split transaction
  var response = await fetch('https://api.paystack.co/transaction/initialize', {
    method: 'POST',
    headers: {
      Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      email: buyerEmail,
      amount: order.totalAmount,
      subaccount: seller.paystackSubaccountCode,
      bearer: 'account',
      reference: 'mkt_' + order.id + '_' + Date.now(),
      metadata: {
        order_id: order.id,
        seller_id: seller.id,
        custom_fields: [
          {
            display_name: 'Order',
            variable_name: 'order_id',
            value: String(order.id),
          },
        ],
      },
    }),
  });

  var data = await response.json();

  // Store the Paystack reference on the order
  await db.orders.update(order.id, {
    paystackReference: data.data.reference,
  });

  return { paymentUrl: data.data.authorization_url };
}

Key points about the checkout flow:

  • Always check that the seller has a valid subaccount code before initializing. Do not let a buyer check out with a seller who has not completed onboarding.
  • Store the Paystack reference on your order so you can match the webhook to the order later.
  • Include the order ID and seller ID in the metadata. This helps with debugging and dashboard lookups on Paystack's side.
  • Use a predictable reference format like mkt_{orderId}_{timestamp} so you can trace transactions in Paystack's dashboard.

Webhook Handling for Split Transactions

The charge.success webhook is where you confirm the payment and update your order. For split transactions, verify that the split happened as expected.

// Webhook handler for marketplace split transactions
var crypto = require('crypto');

app.post('/webhooks/paystack', async function(req, res) {
  // Step 1: Verify the webhook signature
  var 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(401).send('Invalid signature');
  }

  var event = req.body;

  if (event.event === 'charge.success') {
    var data = event.data;

    // Step 2: Find the order by reference
    var order = await db.orders.findByPaystackReference(data.reference);

    if (!order) {
      console.log('No order found for reference: ' + data.reference);
      return res.status(200).send('OK');
    }

    // Step 3: Verify the amount matches
    if (data.amount !== order.totalAmount) {
      console.error(
        'Amount mismatch: expected ' + order.totalAmount +
        ' got ' + data.amount
      );
      await db.orders.update(order.id, { status: 'amount_mismatch' });
      return res.status(200).send('OK');
    }

    // Step 4: Record the split details
    await db.orders.update(order.id, {
      status: 'paid',
      paystackFees: data.fees,
      subaccountCode: data.subaccount ? data.subaccount.subaccount_code : null,
      paidAt: data.paid_at,
    });

    // Step 5: Notify the seller
    await notifySeller(order.sellerId, order.id);
  }

  res.status(200).send('OK');
});

Always return 200 to the webhook, even if you encounter an error processing it. If you return a non-200 status, Paystack will retry the webhook, and duplicate processing can cause problems. Handle errors internally (log them, queue for retry) rather than signaling failure to Paystack.

Handling Refunds on Split Transactions

Refunds are where split marketplace architecture gets complicated. When you refund a split transaction, the refund comes from your main Paystack account balance, not from the subaccount. If the seller has already been settled, you are paying the refund out of your own money.

// Refund a marketplace order
async function refundOrder(orderId) {
  var order = await db.orders.findById(orderId);

  // Initiate the refund through Paystack
  var response = await fetch('https://api.paystack.co/refund', {
    method: 'POST',
    headers: {
      Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      transaction: order.paystackReference,
    }),
  });

  var data = await response.json();

  if (data.status) {
    await db.orders.update(orderId, {
      status: 'refunded',
      refundedAt: new Date().toISOString(),
    });

    // Track the vendor recovery separately
    // The refund came from YOUR balance, not the vendor's
    await db.vendorRecoveries.create({
      orderId: orderId,
      sellerId: order.sellerId,
      amount: order.totalAmount,
      status: 'pending_recovery',
    });
  }

  return data;
}

To mitigate refund risk in a marketplace:

  • Use delayed settlement. Configure subaccounts for manual settlement so you have a window to process refunds before the vendor gets paid. Release settlement only after the return/refund window closes.
  • Build a vendor recovery process. When you refund a settled transaction, deduct the amount from the vendor's next payout or invoice them separately. Track these recoveries in your database.
  • Set clear refund policies. Communicate to vendors that refunds will be deducted from future earnings. Make this part of your vendor terms of service.
  • Partial refunds. Paystack supports partial refunds. If the buyer returns one item from a multi-item order, refund only that item's amount rather than the full order.

Database Schema Design

Your database needs to track the marketplace entities and their Paystack connections. Here is a minimal schema that covers the core relationships.

// Database schema (conceptual, works with any SQL or document database)

// sellers table
// id, email, shop_name, paystack_subaccount_code, bank_code,
// account_number, verified_account_name, onboarding_complete,
// created_at

// products table
// id, seller_id (FK to sellers), name, price_in_kobo, description,
// active, created_at

// orders table
// id, buyer_email, seller_id (FK to sellers), product_id,
// quantity, total_amount_kobo, status, paystack_reference,
// paystack_fees, subaccount_code, paid_at, refunded_at,
// created_at

// split_records table (for reconciliation)
// id, order_id (FK to orders), paystack_reference,
// expected_vendor_share, actual_vendor_share,
// expected_platform_share, actual_platform_share,
// fee_amount, fee_bearer, reconciled, created_at

// vendor_recoveries table (for refund tracking)
// id, order_id, seller_id, amount, status, recovered_at,
// created_at

The split_records table is critical for reconciliation. For every split transaction, store what you expected each party to receive and what Paystack actually reported. A daily reconciliation job compares these values and flags discrepancies. Without this table, you will not know if a split was calculated incorrectly until a vendor complains.

The vendor_recoveries table tracks money you need to recover from vendors after refunding split transactions. This is your accounts receivable from vendors. Without it, you will lose track of which refunds have been recovered and which are still outstanding.

Operational Edge Cases

These are the situations that come up in production and are easy to miss during initial development.

Seller changes banks mid-transaction. If a seller updates their bank details after a transaction is processed but before settlement, the pending settlement goes to the old bank. New transactions use the new details. Make sure your UI communicates this to sellers: "Your pending earnings will still go to your old account. Only new sales will be settled to the new account."

Seller is not yet onboarded. A seller might list products before completing bank detail collection. Block checkout for products from sellers without a valid subaccount code. Show buyers "This seller is not yet set up to receive payments" rather than letting the checkout fail at the Paystack level.

Multiple products from the same seller in one cart. Straightforward: sum the product prices and initialize one split transaction with the seller's subaccount. The total order amount is split by the same ratio.

Products from different sellers in one cart. This is harder. You have three options: split the cart into separate transactions (one per seller), use multi-split if all sellers are part of the same transaction, or collect the full amount and distribute via transfers. Separate transactions are cleanest for refunds and tracking but mean the buyer sees multiple charges. See building a multi-vendor store with Paystack for a detailed comparison.

Chargebacks on split transactions. If a buyer disputes a charge with their bank, the chargeback comes from your Paystack account. The subaccount is not affected. You bear the chargeback risk and need to recover from the vendor separately. Factor this into your risk assessment and vendor agreements.

For the complete split payments overview including multi-split, fee configuration, and reconciliation, see the Paystack split payments and marketplaces complete guide.

Ship Your Marketplace

Building a marketplace is one of the most ambitious projects a developer can take on. The payment layer is the heart of it, and getting it right means understanding splits, settlements, refunds, and reconciliation deeply.

The McTaba bootcamp teaches full-stack development with real payment integrations. You do not just learn the theory. You build and deploy a working marketplace.

Create a free McTaba account

Key Takeaways

  • A two-sided marketplace on Paystack has three actors: the buyer (pays), the seller (subaccount, receives their share), and the platform (your main account, keeps the commission).
  • Seller onboarding creates a Paystack subaccount for each seller. Validate bank details with the Resolve Account endpoint before creating the subaccount.
  • At checkout, look up the seller's subaccount code from your database and pass it when initializing the transaction. The buyer sees a single charge.
  • The charge.success webhook contains the split breakdown. Verify it matches your expected commission before marking the order as paid.
  • Refunds on split transactions come from your main account balance. If the seller has already been settled, you need to recover their portion outside of Paystack.
  • Store the split details (expected and actual) in your database for every transaction. This is the foundation of your reconciliation process.
  • Start with a simple 80/20 percentage split and bearer: account. Optimize the split model after you have real transaction data.

Frequently Asked Questions

Do I need a special Paystack plan to build a marketplace?
No. Split payments and subaccounts are available on standard Paystack business accounts. You do not need a special plan or approval to start using them. Create your Paystack account, get your API keys, and start building. If you scale to high volumes, you may want to contact Paystack about enterprise support, but the features themselves are available to all businesses.
How do I handle disputes between buyers and sellers?
Paystack handles payment disputes (chargebacks) at the transaction level. Business disputes between buyers and sellers are your responsibility as the platform operator. Build a dispute resolution flow in your platform: let buyers raise issues, let sellers respond, and make a decision about refunds or adjustments. The Paystack split is a settlement mechanism, not a dispute resolution tool.
Can I charge sellers a monthly subscription fee in addition to per-transaction commission?
Yes, but these are separate billing relationships. Use Paystack splits for per-transaction commission, and use Paystack subscriptions, invoices, or a separate billing flow for the monthly fee. Do not try to bake subscription charges into the split configuration.
What if a seller wants to receive payments in mobile money instead of a bank account?
Paystack subaccounts settle to bank accounts. If a seller wants mobile money payouts, you would need to collect the payment with the split, then separately transfer the seller's share to their mobile money wallet using Paystack Transfers (if supported for mobile money in that market) or a different payout provider. This adds complexity but is sometimes necessary in markets where mobile money is more common than bank accounts.
How do I test the marketplace flow before going live?
Use Paystack test mode. Create subaccounts and transactions with your test secret key. Test the full flow from seller onboarding to checkout to webhook handling. Use Paystack's test card numbers to simulate successful and failed payments. When everything works in test mode, switch to your live secret key and recreate the subaccounts with real bank details.

Ready to build real-world apps?

Join the McTaba Labs full-stack marathon (4 months full-time · 6 months part-time). Learn M-Pesa, USSD, and WhatsApp engineering while shipping 8 production apps.

Apply to the McTaba Marathon