Freelance platforms live and die on one moment: the client has to pay before the work is done, and the freelancer has to start work before they've been paid. Every platform solves it the same way in principle: hold the money in the middle until the work is delivered.
This post shows how to do that with an escrow contract instead of your own balance, using one API call per milestone. No account, no API key, and your platform never holds the funds.
The flow
- The client accepts a proposal. Your server creates an escrow checkout for the milestone.
- The client funds it from their own wallet. The money locks in a smart contract.
- You see the milestone as funded, and the freelancer starts.
- The client approves the work and releases, or the freelancer claims once the review period ends. If they disagree, either side opens a dispute.
Step 1: create the checkout
Call this from your server when a milestone is accepted. Each request carries that freelancer's wallet, so every milestone pays its own freelancer.
// Server only. Browser calls from other sites are refused.
import { randomBytes } from 'node:crypto';
const orderRef = `ms-${milestone.id}-${randomBytes(4).toString('hex')}`; // keep a random part
const res = await fetch('https://checkout.vaultion.org/v1/stores/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
store: {
url: 'https://yourplatform.com',
name: 'Your Platform',
email: freelancer.email, // the freelancer gets the escrow emails
},
orderRef,
name: `Milestone ${milestone.number}: ${project.title}`.slice(0, 128),
priceMinor: milestone.amountCents, // US cents; escrow needs at least 5000 ($50)
escrow: 'required', // escrow only
receiving: { evm: freelancer.wallet }, // one EVM address covers Base, Ethereum, Arbitrum, BNB Smart Chain
returnUrl: `https://yourplatform.com/projects/${project.id}`,
}),
});
const session = await res.json();
if (!res.ok) throw new Error(session.error);
await db.milestones.update(milestone.id, { orderRef, sessionId: session.sessionId });
// redirect the client to 'https://checkout.vaultion.org' + session.checkoutPath
Two details worth getting right:
-
Store the
orderRefbefore you redirect. The sameorderRefreturns the same checkout, so a timed-out request can be retried without opening a second escrow. -
Keep a random part in it. Someone who knows your store settings and a guessable
orderRefcould open that checkout first, and yours would then fail withorder_ref_conflict.
Step 2: turn statuses into project states
Your backend reads the session status and maps it to what your UI shows:
| status | What it means | Your project state |
|---|---|---|
unpaid |
No confirmed payment yet | Awaiting payment |
escrow_funded |
Money is locked in the contract | Work can start |
escrow_disputed |
A dispute is open | Paused |
escrow_pending |
A ruling was given and its challenge window is running | Paused, outcome pending |
escrow_released |
The freelancer was paid (or can claim it) | Paid |
escrow_refunded |
The client was refunded (or can claim it) | Refunded |
escrow_closed |
The amount was split between both sides | Settled |
escrow_late |
Funded after the price deadline | Review before starting |
Step 3: poll, don't wait for a webhook
There are no webhooks yet, so read the status from a job. Poll every 30 to 60 seconds while the client is on the checkout, then every few minutes until the escrow resolves:
async function syncMilestone(m) {
const r = await fetch(`https://checkout.vaultion.org/v1/stores/sessions/${m.sessionId}`);
if (!r.ok) return; // try again next run
const { status } = await r.json();
if (status !== m.status) {
await db.milestones.update(m.id, { status });
await notifyProjectParticipants(m, status); // your own in-app notifications
}
}
A status changes only from a confirmed on-chain receipt. A client landing back on your returnUrl proves nothing on its own, so don't mark anything funded from the redirect.
What you don't have to build
- Custody. The contract holds the money and can only pay the client or that milestone's freelancer.
- Escrow emails. Both sides are emailed when the escrow is funded, when a dispute opens, when a ruling is given, before the review period ends, and when it completes.
- Dispute handling. Either side opens a dispute and submits evidence, and a Vaultion reviewer rules at no charge. That is not decentralized: you are trusting Vaultion's reviewers. A separate guardian can pause a ruling but never redirect funds.
The limits, so you can decide
- Clients pay in USDC or USDT on one of six networks, so this sits next to card payments rather than replacing them.
- $50 minimum per escrow, so very small milestones don't fit.
- No sandbox: integrate against production with a real $50 escrow.
- No platform commission inside the escrow. The only fee in it is Vaultion's, paid by the client (4% below 500 USDC, 3% from 500, 2% from 5,000), so charge your platform fee separately.
The full field reference, every status and code samples in Node, PHP and cURL are in the escrow API docs. A longer version of this walkthrough, with every status mapped, is on the site: escrow for freelance platforms.
Disclosure: I work on Vaultion, the escrow API used here. This post was drafted with AI assistance.
Top comments (0)