DEV Community

Vaultion
Vaultion

Posted on Originally published at vaultion.org AI-assisted

Adding escrow to a freelance platform with one API call

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

  1. The client accepts a proposal. Your server creates an escrow checkout for the milestone.
  2. The client funds it from their own wallet. The money locks in a smart contract.
  3. You see the milestone as funded, and the freelancer starts.
  4. 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
Enter fullscreen mode Exit fullscreen mode

Two details worth getting right:

  • Store the orderRef before you redirect. The same orderRef returns 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 orderRef could open that checkout first, and yours would then fail with order_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
  }
}
Enter fullscreen mode Exit fullscreen mode

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)