Export as

Escrow Flows

Complete guide to all escrow lifecycle flows — creation, funding, release, dispute, and refund.

This guide walks through every escrow flow in OFFER-HUB: from creating and funding a contract to releasing, disputing, and refunding. Each flow includes step-by-step diagrams and code examples.

Note

OFFER-HUB uses Trustless Work smart contracts on Stellar for non-custodial escrow. All flows ultimately resolve on-chain.

Creating an Escrow

An escrow contract is created after an order is placed and the buyer's funds are reserved off-chain.

Steps

  1. Create the order — Buyer's balance is reserved
  2. Call the escrow creation endpoint — Deploys a Trustless Work contract on Stellar
  3. Wait for confirmation — Contract becomes ESCROW_CREATED
Mermaid
Rendering diagram…

REST

bash
curl -X POST http://localhost:4000/api/v1/orders/ord_xyz789/escrow \
  -H "Authorization: Bearer ohk_live_your_api_key" \
  -H "Content-Type: application/json"

Response:

json
{
  "data": {
    "id": "ord_xyz789",
    "status": "ESCROW_CREATING",
    "escrow": {
      "contractId": "CDLZFC3SYJYD...",
      "status": "creating"
    }
  }
}

TypeScript SDK

ts
import { OfferHubSDK } from '@offerhub/sdk';

const sdk = new OfferHubSDK({
  apiUrl: 'http://localhost:4000',
  apiKey: 'ohk_live_your_api_key',
});

// Create escrow contract
await sdk.escrow.create('ord_xyz789');

// Wait until contract is deployed
await sdk.orders.waitForStatus('ord_xyz789', 'ESCROW_CREATED');
Tip

Contract deployment is asynchronous. Use waitForStatus or subscribe to the order.escrow_created event rather than polling manually.


Funding the Escrow

Once the contract is deployed, the buyer's reserved USDC is transferred on-chain into the smart contract.

Steps

  1. Call the fund endpoint — Triggers an on-chain USDC transfer
  2. Platform signs — The buyer's encrypted key authorizes the transaction
  3. Stellar confirms — Transaction lands on-chain (5–10 seconds)
  4. Status updates — Order advances to IN_PROGRESS
Mermaid
Rendering diagram…

REST

bash
curl -X POST http://localhost:4000/api/v1/orders/ord_xyz789/escrow/fund \
  -H "Authorization: Bearer ohk_live_your_api_key" \
  -H "Idempotency-Key: fund-ord_xyz789-001"

TypeScript SDK

ts
await sdk.escrow.fund('ord_xyz789');

await sdk.orders.waitForStatus('ord_xyz789', 'IN_PROGRESS');
Warning

Always include an Idempotency-Key when funding. If the request times out, retrying with the same key is safe and prevents double-funding.

Balance changes during funding

StageAvailableReservedOn-Chain (Wallet)In Contract
After reserve50.0050.00100.000.00
After funding50.000.0050.0050.00

Release Conditions

Funds can be released in two ways depending on how the order is configured.

Time-Based Release

The buyer approves release after a deadline or after delivery confirmation within a time window.

Mermaid
Rendering diagram…
  1. Seller marks work as complete
  2. Buyer has a review window (e.g., 72 hours)
  3. If the buyer approves — or the window expires without a dispute — release is triggered

Milestone-Based Release

For larger projects, funds can be split across milestones. Each milestone follows its own create → fund → release cycle.

Mermaid
Rendering diagram…
Tip

Model milestones as separate orders sharing the same project_id. Each order has its own escrow contract and independent lifecycle.


Releasing Funds

When the buyer approves the delivered work, funds are released to the seller in three on-chain transactions.

Steps

StepTransactionSigner
1complete_escrowBuyer confirms delivery
2release_escrowPlatform authorizes release
3Claim fundsSeller receives USDC
Mermaid
Rendering diagram…

REST

bash
curl -X POST http://localhost:4000/api/v1/orders/ord_xyz789/resolution/release \
  -H "Authorization: Bearer ohk_live_your_api_key" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: release-ord_xyz789-001" \
  -d '{"requestedBy": "usr_buyer123"}'

Response:

json
{
  "data": {
    "id": "ord_xyz789",
    "status": "CLOSED",
    "resolution": {
      "type": "released",
      "amount": "50.00",
      "recipient": "usr_seller456",
      "completedAt": "2026-03-01T14:00:00.000Z"
    }
  }
}

TypeScript SDK

ts
await sdk.resolution.release('ord_xyz789', {
  requestedBy: 'usr_buyer123',
});
Note

After release, the seller's internal balance is credited. They can withdraw to their external wallet at any time via the Withdrawals flow.


Raising a Dispute

If the buyer believes the work was not delivered as agreed, they can open a dispute while the order is IN_PROGRESS.

Steps

  1. Buyer opens dispute — Provides a reason
  2. Status changes — Order moves to DISPUTING then DISPUTED
  3. Funds remain locked — Neither party can access the escrow
  4. Platform reviews — Manual or automated resolution begins
Mermaid
Rendering diagram…

REST

bash
curl -X POST http://localhost:4000/api/v1/orders/ord_xyz789/resolution/dispute \
  -H "Authorization: Bearer ohk_live_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "requestedBy": "usr_buyer123",
    "reason": "Work delivered does not match agreed specifications"
  }'

Response:

json
{
  "data": {
    "id": "ord_xyz789",
    "status": "DISPUTED",
    "dispute": {
      "id": "dsp_abc123",
      "reason": "Work delivered does not match agreed specifications",
      "openedAt": "2026-03-01T10:00:00.000Z",
      "openedBy": "usr_buyer123"
    }
  }
}

TypeScript SDK

ts
await sdk.resolution.dispute('ord_xyz789', {
  requestedBy: 'usr_buyer123',
  reason: 'Work delivered does not match agreed specifications',
});
Warning

Disputes can only be opened while the order is IN_PROGRESS. Once released or refunded, the order is final.


Dispute Resolution

After a dispute is opened, the platform reviews evidence and resolves with a release (seller wins) or refund (buyer wins).

Resolution Options

ResolutionOutcomeOn-Chain Transaction
releaseFunds go to sellerresolve_dispute → release
refundFunds return to buyerresolve_dispute → refund
splitCustom split between partiesresolve_dispute → split

REST — Resolve with Refund

bash
curl -X POST http://localhost:4000/api/v1/disputes/dsp_abc123/resolve \
  -H "Authorization: Bearer ohk_live_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "resolution": "refund",
    "note": "Seller did not deliver agreed deliverables"
  }'

REST — Resolve with Release

bash
curl -X POST http://localhost:4000/api/v1/disputes/dsp_abc123/resolve \
  -H "Authorization: Bearer ohk_live_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "resolution": "release",
    "note": "Evidence confirms work was delivered as agreed"
  }'

TypeScript SDK

ts
// Resolve in favor of buyer (refund)
await sdk.disputes.resolve('dsp_abc123', {
  resolution: 'refund',
  note: 'Seller did not deliver agreed deliverables',
});

// Resolve in favor of seller (release)
await sdk.disputes.resolve('dsp_abc123', {
  resolution: 'release',
  note: 'Evidence confirms work was delivered as agreed',
});
Note

Only the platform (using the master API key) can resolve disputes. This requires a co-signature from the Trustless Work smart contract arbiter.


Refund Flow

A refund can result from dispute resolution or, in some configurations, from a direct cancellation before work begins.

Refund via Dispute

Mermaid
Rendering diagram…

Two on-chain transactions are required:

StepTransactionSigner
1dispute_escrowBuyer opens dispute
2resolve_disputePlatform issues refund

Refund via Cancellation

If the order is cancelled before funding (e.g., the seller cannot fulfill), the off-chain reservation is released with no blockchain transaction:

bash
curl -X POST http://localhost:4000/api/v1/orders/ord_xyz789/cancel \
  -H "Authorization: Bearer ohk_live_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{"requestedBy": "usr_seller456", "reason": "Unable to fulfill order"}'
Note

Cancellations before escrow funding are instant — no Stellar transaction required. The buyer's reserved balance is immediately returned to available.

TypeScript SDK — Full Dispute-to-Refund Flow

ts
// Step 1: Buyer raises dispute
await sdk.resolution.dispute('ord_xyz789', {
  requestedBy: 'usr_buyer123',
  reason: 'Work not delivered',
});

// Step 2: Platform fetches the dispute
const disputes = await sdk.disputes.list({ orderId: 'ord_xyz789' });
const dispute = disputes[0];

// Step 3: Platform resolves with refund
await sdk.disputes.resolve(dispute.id, {
  resolution: 'refund',
  note: 'Verified: seller did not deliver',
});

// Buyer's balance is now credited
const buyer = await sdk.users.get('usr_buyer123');
console.log('Buyer available balance:', buyer.balance.available);

Listening to Escrow Events

Subscribe to lifecycle events to keep your application in sync:

ts
const events = sdk.events.subscribe();

events.on('order.escrow_created', (data) => {
  console.log('Contract deployed:', data.contractId);
});

events.on('order.escrow_funded', (data) => {
  console.log('Funds locked:', data.amount, 'USDC');
});

events.on('order.released', (data) => {
  console.log('Released to seller:', data.recipient);
});

events.on('order.disputed', (data) => {
  console.log('Dispute opened:', data.dispute.id);
});

events.on('order.refunded', (data) => {
  console.log('Refunded to buyer:', data.amount, 'USDC');
});

Error Reference

ErrorCauseFix
ESCROW_ALREADY_EXISTSContract already created for this orderCheck status before calling create
ESCROW_NOT_FUNDEDRelease attempted before fundingFund the escrow first
INVALID_STATE_TRANSITIONAction not valid in current statusFollow the state machine
INSUFFICIENT_FUNDSBuyer wallet has insufficient USDCCheck balance before funding
DISPUTE_ALREADY_OPENDuplicate dispute on same orderResolve existing dispute first
FUNDING_TIMEOUTStellar transaction not confirmedCheck order status — do not retry blindly
Tip

Use idempotency keys on all mutating requests (fund, release, dispute). This allows safe retries without risk of duplicate transactions.


Next Steps