Create vendor subaccounts with settle_interval: "manual" (requires Paystack support approval). Funds collected through split transactions accumulate in the subaccount without being settled. When the release condition is met (e.g., buyer confirms delivery), call POST /settlement or transfer from your held balance to the vendor. Track hold state in your database with release_at timestamps.
CREATE TABLE payout_holds (
id SERIAL PRIMARY KEY,
vendor_id INTEGER NOT NULL,
reference TEXT NOT NULL,
amount NUMERIC(12, 2) NOT NULL,
currency TEXT DEFAULT 'NGN',
hold_reason TEXT, -- 'delivery_pending' | 'dispute_window' | 'event_date'
hold_until TIMESTAMPTZ NOT NULL,
released_at TIMESTAMPTZ,
status TEXT DEFAULT 'held', -- 'held' | 'released' | 'disputed' | 'refunded'
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_holds_release ON payout_holds(status, hold_until);
// On charge.success, create a hold record
if (event.event === 'charge.success') {
var holdUntil = new Date();
holdUntil.setDate(holdUntil.getDate() + 7); // 7-day dispute window
await db.payoutHolds.create({
vendor_id: order.vendor_id,
reference: event.data.reference,
amount: event.data.amount / 100 * 0.9, // Vendor share (90%)
hold_reason: 'dispute_window',
hold_until: holdUntil,
status: 'held',
});
}
// Daily cron: release matured holds
async function releaseMatureHolds() {
var now = new Date();
var holds = await db.payoutHolds.findAll({
where: { status: 'held', hold_until: { lte: now } }
});
for (var hold of holds) {
var vendor = await db.vendors.findById(hold.vendor_id);
await initiateTransfer(vendor.recipient_code, hold.amount, hold.currency);
await db.payoutHolds.update(
{ status: 'released', released_at: now },
{ where: { id: hold.id } }
);
}
}
This guide is part of the Paystack split payments 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