Build a freelance escrow platform by collecting milestone payments via Paystack (funds land in your Paystack balance), holding them in a "pending release" state in your database, and transferring to the freelancer's registered bank account via the Paystack Transfers API only when the client explicitly approves the deliverable.
Tables: projects (client_id, freelancer_id, title, status), milestones (project_id, title, amount, status, paystack_ref, funded_at, released_at), freelancers (user_id, name, bank_code, account_number, paystack_recipient_code).
var PLATFORM_FEE_RATE = 0.08; // 8%
async function fundMilestone(clientEmail, milestoneId) {
var milestone = await db.milestones.findById(milestoneId);
if (milestone.status !== 'awaiting_funding') throw new Error('Milestone not ready to fund');
var reference = 'MILE-' + milestoneId + '-' + Date.now();
var res = 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: clientEmail,
amount: milestone.amount,
currency: 'NGN',
reference,
metadata: { milestone_id: milestoneId, purpose: 'milestone_funding' },
}),
});
await db.milestones.update(milestoneId, { paystack_ref: reference });
return (await res.json()).data.authorization_url;
}
// Webhook: mark milestone as funded
if (event.event === 'charge.success' && event.data.metadata.purpose === 'milestone_funding') {
var milestoneId = event.data.metadata.milestone_id;
await db.milestones.update(milestoneId, { status: 'funded', funded_at: new Date() });
// Notify freelancer: "Milestone funded — you can start work"
var milestone = await db.milestones.findById(milestoneId);
await notifyFreelancer(milestone.project_id, 'Milestone "' + milestone.title + '" funded. Start working!');
}
// Client approves work — release funds to freelancer
async function approveMilestone(clientId, milestoneId) {
var milestone = await db.milestones.findById(milestoneId);
var project = await db.projects.findById(milestone.project_id);
if (project.client_id !== clientId) throw new Error('Not your project');
if (milestone.status !== 'funded') throw new Error('Milestone not funded or already released');
var freelancer = await db.freelancers.findByUser(project.freelancer_id);
// Register recipient if not yet done
if (!freelancer.paystack_recipient_code) {
var rcpRes = await fetch('https://api.paystack.co/transferrecipient', {
method: 'POST',
headers: { Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'nuban', name: freelancer.name, account_number: freelancer.account_number, bank_code: freelancer.bank_code, currency: 'NGN' }),
});
var code = (await rcpRes.json()).data.recipient_code;
await db.freelancers.update(freelancer.id, { paystack_recipient_code: code });
freelancer.paystack_recipient_code = code;
}
// Deduct platform fee
var platformFee = Math.floor(milestone.amount * PLATFORM_FEE_RATE);
var payout = milestone.amount - platformFee;
await fetch('https://api.paystack.co/transfer', {
method: 'POST',
headers: { Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({
source: 'balance',
amount: payout,
recipient: freelancer.paystack_recipient_code,
reason: 'Milestone payout: ' + milestone.title,
metadata: { milestone_id: milestoneId },
}),
});
await db.milestones.update(milestoneId, { status: 'released', released_at: new Date() });
await notifyFreelancer(project.freelancer_id, 'Payment released: NGN ' + (payout / 100));
}
// Dispute: admin can refund to client or release to freelancer
async function resolveDispute(milestoneId, decision) {
if (decision === 'refund_client') {
var milestone = await db.milestones.findById(milestoneId);
var project = await db.projects.findById(milestone.project_id);
var client = await db.clients.findById(project.client_id);
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: milestone.paystack_ref }),
});
await db.milestones.update(milestoneId, { status: 'refunded' });
} else if (decision === 'release_freelancer') {
await approveMilestone('admin', milestoneId); // admin overrides
}
}
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
See build a tutoring marketplace with split payments for a related platform model using Paystack Subaccounts instead of manual transfers.
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