Bonaventure OgetoBy Bonaventure Ogeto|

Build a Freelance Escrow Style Platform

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.

Milestone Setup and Payment Collection

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!');
}

Work Approval and Freelancer Payout

// 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
  }
}

Learn More

See build a tutoring marketplace with split payments for a related platform model using Paystack Subaccounts instead of manual transfers.

Sign up for the McTaba newsletter

Key Takeaways

  • Funds land in your Paystack balance — you hold them on behalf of the client until work is approved.
  • Structure projects as milestones: client pays per milestone, not the whole project upfront.
  • Release funds via Paystack Transfers only after explicit client approval — never automatically.
  • Register freelancers as Paystack Transfer Recipients at onboarding so payouts are instant.
  • Charge a platform fee (5-10%) at release by transferring less than the full milestone amount.

Frequently Asked Questions

Can Paystack legally hold funds in escrow for me?
Paystack is not an escrow provider — it is a payment processor. When clients pay, the funds land in your Paystack settlement balance. You are technically holding the funds on behalf of both parties. This is fine for short-term holds (days to weeks) but get legal advice if your platform holds funds for extended periods. For formal escrow in Nigeria, you would need a licensed escrow agent.
What happens if the client never approves the work?
Set a maximum hold period (e.g., 14 days after the freelancer marks work as delivered). If the client does not respond within 14 days, auto-release the funds to the freelancer. The client's failure to review within the window counts as implicit approval. State this policy clearly in your terms of service.
How do I protect the freelancer from a client who keeps requesting revisions?
Specify a revision limit in the milestone scope (e.g., 2 revisions included). After the limit, additional revisions require a new milestone payment. Include the scope and revision count in the milestone description field so both parties agree before the client funds the milestone.

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