End-to-end tutorial — scaffold a project, run the Orchestrator, wire the SDK, and ship a minimal marketplace with buyer/seller signup, listings, checkout with escrow, and fund release.
Tip
Hackathon fast-track. This is the recommended starting point for hackathon participants. Every command and code sample is verified against the real Orchestrator repository. You will have a running marketplace skeleton in under an hour.
What You Will Build
By the end of this guide you will have:
A running OFFER-HUB Orchestrator instance (local, Docker-backed)
A minimal Node.js / TypeScript marketplace backend wired to the SDK
Buyer and seller user accounts with Stellar wallets
A simple listing flow tied to an escrow-protected order
import { OfferHubSDK } from '@offerhub/sdk';
export const sdk = new OfferHubSDK({
apiUrl: process.env.OFFERHUB_API_URL!,
apiKey: process.env.OFFERHUB_API_KEY!,
});
Note
The SDK is server-side only. Never instantiate it with your API key in browser code.
Part 3 — User Signup
Every participant in your marketplace — buyer or seller — needs an OFFER-HUB user record. This is created once, typically at registration time in your backend.
What the SDK Creates
When you call sdk.users.create, the Orchestrator:
Stores the user in its database with an available and reserved balance (both start at 0.00)
Derives a unique Stellar keypair for that user and encrypts the private key at rest
Returns the user ID you use for all subsequent API calls
Store buyer.id and seller.id in your own database linked to your internal user records. The externalUserId field is how you map back from OFFER-HUB to your own user system.
Part 4 — Fund the Buyer Account
Before a buyer can place an order they need a USDC balance. On testnet, you use the Stellar Friendbot to mint test USDC.
How Deposits Work
The Orchestrator watches the Stellar network for incoming USDC payments to each user's assigned address. When it detects a payment, it credits the user's available balance automatically.
Mermaid
Rendering diagram…
Get and Display the Deposit Address
Create src/02-deposit.ts:
typescript
import 'dotenv/config';
import { sdk } from './sdk.js';
async function showDepositAddress(userId: string) {
const info = await sdk.wallet.getDepositAddress(userId);
console.log('─────────────────────────────────────────');
console.log('Send USDC to fund this account:');
console.log(' Network :', info.network); // testnet
console.log(' Asset :', info.asset.code); // USDC
console.log(' Address :', info.address); // G...
console.log('─────────────────────────────────────────');
console.log('Use Stellar Friendbot to get test USDC:');
console.log(` https://friendbot.stellar.org?addr=${info.address}`);
return info;
}
// Replace with your actual buyer ID from Part 3
showDepositAddress('usr_buyer123').catch(console.error);
$
npx tsx src/02-deposit.ts
Check the Balance
Once the deposit is detected (usually within a few seconds on testnet):
A "listing" in your marketplace is a concept you own — OFFER-HUB does not store listings. What OFFER-HUB handles is the financial side: creating an order that links a buyer to a seller for a specific amount.
The Listing → Order Mapping
Your marketplace creates a listing record. When a buyer clicks "Buy", your backend calls sdk.orders.create to lock the financial agreement:
Mermaid
Rendering diagram…
Create src/03-listing-and-order.ts:
typescript
import 'dotenv/config';
import { sdk } from './sdk.js';
// Your marketplace owns the listing data
const listing = {
id: 'listing-001',
title: 'Logo Design — Startup Package',
description: 'Professional logo design with 3 concepts, 2 revision rounds',
price: '75.00',
currency: 'USD',
sellerId: 'usr_seller456', // from Part 3
};
async function checkout(buyerId: string) {
// Step 1: Check buyer has enough funds
const balance = await sdk.balance.get(buyerId);
if (parseFloat(balance.available) < parseFloat(listing.price)) {
throw new Error(
`Insufficient balance: have ${balance.available}, need ${listing.price}`
);
}
// Step 2: Create the order
const order = await sdk.orders.create({
buyerId,
sellerId: listing.sellerId,
amount: listing.price,
currency: listing.currency,
title: listing.title,
description: listing.description,
});
console.log('Order created:', order.id, '| Status:', order.status);
// → ORDER_CREATED
// Step 3: Reserve the buyer's funds
await sdk.orders.reserve(order.id);
console.log('Funds reserved for order:', order.id);
// Verify the balance change
const updatedBalance = await sdk.balance.get(buyerId);
console.log('Available after reserve:', updatedBalance.available);
// decreased by listing.price
console.log('Reserved after reserve:', updatedBalance.reserved);
// increased by listing.price
return order;
}
// Replace with your actual buyer ID from Part 3
checkout('usr_buyer123').catch(console.error);
$
npx tsx src/03-listing-and-order.ts
Note
sdk.orders.reserve moves funds from available to reserved in the internal ledger. Nothing goes on-chain yet — that happens in Part 6.
Part 6 — Fund the Escrow
Once funds are reserved, the next step is locking them into a Soroban smart contract via Trustless Work. This is the moment the buyer's payment leaves the off-chain ledger and becomes non-custodially held on Stellar.
Escrow Funding Flow
Mermaid
Rendering diagram…
Create src/04-escrow.ts:
typescript
import 'dotenv/config';
import { sdk } from './sdk.js';
async function fundEscrow(orderId: string) {
// Step 1: Deploy the Soroban smart contract
await sdk.orders.createEscrow(orderId);
console.log('Deploying escrow contract for order:', orderId);
// Step 2: Wait for contract deployment (async on-chain)
await sdk.orders.waitForStatus(orderId, 'ESCROW_CREATED');
console.log('Contract deployed. Status: ESCROW_CREATED');
// Step 3: Transfer USDC from buyer's wallet into the contract
await sdk.orders.fundEscrow(orderId);
console.log('Funding escrow...');
// Step 4: Wait for on-chain confirmation (3–10 seconds on testnet)
await sdk.orders.waitForStatus(orderId, 'IN_PROGRESS');
console.log('Escrow funded! Order is now IN_PROGRESS');
console.log('Funds are non-custodially held in the Soroban contract.');
}
// Replace with your actual order ID from Part 5
fundEscrow('ord_xyz789').catch(console.error);
$
npx tsx src/04-escrow.ts
Warning
Always call waitForStatus after escrow.create and escrow.fund. Both operations are asynchronous — they dispatch Stellar transactions and resolve when the chain confirms. Do not call the next step before the previous one completes.
Balance State After Funding
Ledger field
Before fund
After fund
available
25.00
25.00
reserved
75.00
0.00
On-chain (contract)
0 USDC
75 USDC
The reserved balance becomes 0.00 because the funds are now truly on-chain in the smart contract — the Orchestrator no longer holds them.
Part 7 — Release Funds to the Seller
When the buyer is satisfied with the delivered work, they approve release. The Orchestrator co-signs the smart contract call, and USDC flows to the seller's Stellar address.
Release Flow
Mermaid
Rendering diagram…
Create src/05-release.ts:
typescript
import 'dotenv/config';
import { sdk } from './sdk.js';
async function releaseToSeller(orderId: string, buyerId: string, sellerId: string) {
// Buyer approves the completed work
await sdk.orders.release(orderId, "Buyer approved the order");
console.log('Release initiated...');
// Wait for settlement
await sdk.orders.waitForStatus(orderId, 'CLOSED');
console.log('Order CLOSED. Funds released to seller.');
// Seller can now see their updated balance
const sellerBalance = await sdk.balance.get(sellerId);
console.log('Seller available balance:', sellerBalance.available);
// Increased by the order amount
// Seller can withdraw to any Stellar address
const withdrawal = await sdk.withdrawals.create({
userId: sellerId,
amount: '75.00',
destinationAddress: 'GXXXXXXXX...', // seller's external wallet
});
console.log('Withdrawal requested:', withdrawal.id);
}
// Replace with your actual IDs from previous steps
releaseToSeller('ord_xyz789', 'usr_buyer123', 'usr_seller456').catch(console.error);
$
npx tsx src/05-release.ts
Part 8 — Dispute Path
If the buyer is not satisfied, they can open a dispute before approving release. Funds remain locked in the contract until the platform resolves the dispute.
Dispute and Resolution Flow
Mermaid
Rendering diagram…
Create src/06-dispute.ts:
typescript
import 'dotenv/config';
import { sdk } from './sdk.js';
async function handleDispute(
orderId: string,
buyerId: string,
orderAmount: string
) {
// Step 1: Buyer opens a dispute (must be IN_PROGRESS)
await sdk.disputes.open(orderId, {
reason: 'Delivered work does not match agreed specifications',
evidence: [],
});
console.log('Dispute opened. Funds frozen in contract.');
// Step 2: Platform fetches the dispute record
const disputes = await sdk.disputes.list({ orderId });
const dispute = disputes[0];
console.log('Dispute ID:', dispute.id);
// Step 3: Platform reviews evidence and resolves
// Option A: Refund the buyer
await sdk.disputes.resolve(dispute.id, {
decision: 'FULL_REFUND',
releaseAmount: '0.00',
refundAmount: orderAmount,
note: 'Verified: deliverables did not meet specification',
});
// Option B (alternative): Release to seller
// await sdk.disputes.resolve(dispute.id, {
// decision: 'FULL_RELEASE',
// releaseAmount: orderAmount,
// refundAmount: '0.00',
// note: 'Verified: work was delivered as agreed',
// });
await sdk.orders.waitForStatus(orderId, 'REFUNDED');
console.log('Order REFUNDED. Buyer balance credited.');
const buyerBalance = await sdk.balance.get(buyerId);
console.log('Buyer available balance (after refund):', buyerBalance.available);
}
// Replace with your actual IDs
handleDispute('ord_xyz789', 'usr_buyer123', '75.00').catch(console.error);
$
npx tsx src/06-dispute.ts
Note
Only the platform (using the master API key) can resolve disputes. Dispute resolution requires a co-signature from the Trustless Work arbiter smart contract, ensuring neither party can unilaterally steal funds.
Part 9 — Real-Time Events
Subscribe to the Orchestrator's SSE stream to keep your UI in sync without polling.
Available Events
Event
Fired when
balance.credited
Deposit detected on-chain
order.created
sdk.orders.create succeeds
order.funds_reserved
sdk.orders.reserve succeeds
order.escrow_created
Soroban contract deployed
order.escrow_funded
USDC locked in contract
order.released
Seller receives USDC
order.disputed
Buyer opens dispute
order.refunded
Buyer refunded
order.closed
Order fully settled
Create src/07-events.ts:
typescript
import 'dotenv/config';
// OFFER-HUB emits Server-Sent Events at GET /api/v1/events
// Standard EventSource works in Node 18+ and all modern browsers.
const EVENTS_URL = `${process.env.OFFERHUB_API_URL}/api/v1/events`;
const API_KEY = process.env.OFFERHUB_API_KEY!;
// Node.js EventSource (install eventsource package if needed)
// In a browser you'd use new EventSource(url) directly.
const { EventSource } = await import('eventsource');
const es = new EventSource(EVENTS_URL, {
headers: { Authorization: `Bearer ${API_KEY}` },
});
es.onopen = () => {
console.log('Connected to OFFER-HUB event stream');
};
es.onmessage = (event) => {
const data = JSON.parse(event.data);
switch (data.eventType) {
case 'balance.credited':
console.log(`💰 Balance credited: +${data.payload.amount} USDC for user ${data.payload.userId}`);
break;
case 'order.escrow_funded':
console.log(`🔒 Escrow funded: order ${data.payload.orderId} is now IN_PROGRESS`);
break;
case 'order.released':
console.log(`✅ Funds released: order ${data.payload.orderId} CLOSED`);
break;
case 'order.disputed':
console.log(`⚠️ Dispute opened: order ${data.payload.orderId}`);
break;
case 'order.refunded':
console.log(`↩️ Refund processed: order ${data.payload.orderId}`);
break;
default:
console.log(`Event: ${data.eventType}`, data.payload);
}
};
es.onerror = (err) => {
console.error('Event stream error:', err);
};
// Keep alive — press Ctrl+C to stop
console.log('Listening for events. Press Ctrl+C to stop.');
$
npx tsx src/07-events.ts
Tip
In a real app, run this subscriber in your backend and use your WebSocket layer, push notifications, or polling fallback to update your frontend clients.
Part 10 — End-to-End Happy Path
Here is the complete happy path in a single script you can run for a quick demo or hackathon presentation:
typescript
import 'dotenv/config';
import { sdk } from './sdk.js';
async function fullDemoFlow() {
console.log('\n=== OFFER-HUB Marketplace Demo ===\n');
// 1. Register participants
const buyer = await sdk.users.create({
externalUserId: `demo-buyer-${Date.now()}`,
email: 'alice@demo.com',
type: 'BUYER',
});
const seller = await sdk.users.create({
externalUserId: `demo-seller-${Date.now()}`,
email: 'bob@demo.com',
type: 'SELLER',
});
console.log('✓ Buyer:', buyer.id);
console.log('✓ Seller:', seller.id);
// 2. Get buyer deposit address (fund manually via Friendbot on testnet)
const deposit = await sdk.wallet.getDepositAddress(buyer.id);
console.log('\nFund the buyer via Friendbot:');
console.log(` https://friendbot.stellar.org?addr=${deposit.address}`);
console.log('\nPress Ctrl+C and re-run after funding, or use a pre-funded testnet account.\n');
// ⚠️ In a real hackathon demo, fund the buyer first, then continue.
// For a fully automated demo, inject a pre-funded externalUserId here.
// 3. Place an order
const order = await sdk.orders.create({
buyerId: buyer.id,
sellerId: seller.id,
amount: '50.00',
currency: 'USD',
title: 'Demo Service',
description: 'End-to-end demo for hackathon',
});
console.log('✓ Order created:', order.id);
// 4. Reserve funds
await sdk.orders.reserve(order.id);
console.log('✓ Funds reserved');
// 5. Create + fund escrow
await sdk.orders.createEscrow(order.id);
await sdk.orders.waitForStatus(order.id, 'ESCROW_CREATED');
console.log('✓ Escrow contract deployed');
await sdk.orders.fundEscrow(order.id);
await sdk.orders.waitForStatus(order.id, 'IN_PROGRESS');
console.log('✓ Escrow funded (USDC on-chain)');
// 6. Simulate work delivery + buyer approval
await sdk.orders.release(order.id, "Buyer approved the order");
await sdk.orders.waitForStatus(order.id, 'CLOSED');
console.log('✓ Funds released to seller');
// 7. Check seller balance
const sellerBalance = await sdk.balance.get(seller.id);
console.log('\nSeller balance:', sellerBalance.available, sellerBalance.currency);
console.log('\n=== Demo complete! ===');
}
fullDemoFlow().catch(console.error);
$
npx tsx src/full-demo.ts
Part 11 — Error Handling
Wrap SDK calls in try/catch and handle typed errors for a production-quality integration:
typescript
import {
InsufficientFundsError,
NotFoundError,
ValidationError,
InvalidTransitionError,
OfferHubError,
} from '@offerhub/sdk';
async function safeCreateOrder(buyerId: string, sellerId: string, amount: string) {
try {
const order = await sdk.orders.create({
buyerId,
sellerId,
amount,
currency: 'USD',
title: 'Service',
});
await sdk.orders.reserve(order.id);
return order;
} catch (error) {
if (error instanceof InsufficientFundsError) {
// Show "Please add funds" UI to the buyer
console.error(`Need ${error.required} USDC, balance is ${error.available}`);
} else if (error instanceof NotFoundError) {
// User ID does not exist — re-register flow
console.error(`${error.resourceType} not found`);
} else if (error instanceof ValidationError) {
// Bad request body — log and fix
console.error('Validation failed:', error.errors);
} else if (error instanceof InvalidTransitionError) {
// State machine violation — check current order status
console.error(`Cannot transition from state: ${error.currentState}`);
} else if (error instanceof OfferHubError) {
// Generic orchestrator error
console.error('Orchestrator error:', error.message);
} else {
throw error; // Re-throw unexpected errors
}
}
}
Common Errors Reference
Error class
HTTP
When it happens
Fix
InsufficientFundsError
422
Buyer balance too low
Check balance.available before creating order
InvalidTransitionError
409
Wrong order state for action
Follow the state machine
NotFoundError
404
Unknown ID
Verify IDs are from create responses
ValidationError
400
Missing/invalid fields
Check required fields in SDK types
OfferHubError
5xx
Stellar/infrastructure issue
Retry with same idempotency key
Safe Retries with Idempotency Keys
typescript
// Attach a stable, deterministic key to each state-changing operation
const idempotentSdk = sdk.withIdempotencyKey(
'550e8400-e29b-41d4-a716-446655440005'
);
// Safe to retry — same key returns the cached result
await idempotentSdk.orders.fundEscrow(orderId);
Warning
Always use idempotency keys for escrow.fund, escrow.create, resolution.release, and resolution.dispute. Stellar transactions are irreversible — duplicates cannot be undone.
Part 12 — Project Structure Recap
Here is the final file layout of the marketplace app you built: