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.
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:
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.
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.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
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:
mkt_{orderId}_{timestamp} so you can trace transactions in Paystack's dashboard.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.
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:
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.
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.
KES 2,999
The definitive guide to building payment systems with Paystack — from your first transaction to production-grade payment infrastructure across Africa. 9 modules, 47 lessons. Free preview available.
Start Paystack Course