Per-endpoint reference for the OFFER-HUB escrow API — contract creation and funding — with request parameters, contract signer roles, valid state transitions, error codes, and cURL plus TypeScript SDK examples.
An OFFER-HUB escrow is a Stellar smart contract, deployed through Trustless Work, that holds a buyer's funds until the order is resolved. Two endpoints create that contract and move money into it; three more (release, refund, dispute) settle it — see Resolution Endpoints.
Method
Path
Success status
Controller handler
POST
/api/v1/orders/{id}/escrow
201 Created
OrdersController.createEscrow
POST
/api/v1/orders/{id}/escrow/fund
201 Created
OrdersController.fundEscrow
Both handlers sit in OrdersController (apps/api/src/modules/orders/orders.controller.ts) and delegate to OrdersService.createEscrow / OrdersService.fundEscrow (apps/api/src/modules/orders/orders.service.ts). With the global /api/v1 prefix from apps/api/src/main.ts, the full paths are POST /api/v1/orders/{id}/escrow and POST /api/v1/orders/{id}/escrow/fund.
Note
This page is written from the Orchestrator source: apps/api/src/modules/orders/{orders.controller.ts,orders.service.ts,exceptions/orders.exceptions.ts}, apps/api/src/providers/trustless-work/clients/escrow.client.ts, and the enum tables in packages/shared/src/enums/. The apps/api/src/modules/escrow directory currently holds only a dto/.gitkeep — the escrow lifecycle is implemented in the orders, resolution, and trustless-work provider modules, which is where the citations above point.
Common behavior
Authentication and scope
OrdersController declares no guards and no scopes — no @UseGuards(ApiKeyGuard, ScopeGuard) and no @Scopes(...) in apps/api/src/modules/orders/orders.controller.ts. These routes are not authenticated and not scope-gated in the current Orchestrator. The examples still send Authorization: Bearer … because that is the convention across these docs, and because a future release may add the guard.
Aspect
Value on these routes
Source
Authentication
None enforced (no ApiKeyGuard)
orders.controller.ts
Required scope
None (no ScopeGuard / @Scopes)
orders.controller.ts
Rate limiting
Yes — global RateLimitGuard via APP_GUARD
apps/api/src/app.module.ts
Request body
None — neither handler declares @Body()
orders.controller.ts
Tip
Neither endpoint takes a body. The amount is always read from the order (order.amount), and the currency from order.currency, so a request body is ignored entirely. Because no DTO is bound, the global ValidationPipe has nothing to validate here.
Success status codes
Neither handler sets @HttpCode, so NestJS applies its default 201 Created for POST routes. The resolution endpoints set 200 OK explicitly (@HttpCode(HttpStatus.OK)) except dispute, which sets 201 — that is why a release returns 200 while escrow creation returns 201.
Response envelope
ResponseInterceptor (apps/api/src/common/interceptors/response.interceptor.ts) wraps the handler's { success, data } payload because isAlreadyWrapped only skips wrapping when a meta key is present or the payload has exactly one key:
Errors use the single-level envelope from GlobalExceptionFilter (apps/api/src/common/filters/global-exception.filter.ts): { "error": { "code", "message", "details?" } }.
Idempotency
Danger
Neither endpoint supports the Idempotency-Key header. IdempotencyGuard and IdempotencyInterceptor exist (apps/api/src/common/guards/idempotency.guard.ts, apps/api/src/common/interceptors/idempotency.interceptor.ts) but are not attached to OrdersController, so a key sent to these routes is ignored. A replay is blocked by the state machine instead: once the order has moved to ESCROW_FUNDING or IN_PROGRESS, a repeated create or fund call fails with 400 INVALID_STATE and touches neither balances nor the chain. See Idempotency for the endpoints that do honor the header.
Contract roles and signer identity
EscrowClient.createEscrow (apps/api/src/providers/trustless-work/clients/escrow.client.ts) deploys to /deployer/single-release or /deployer/multi-release with a fixed role set:
Trustless Work role
Bound to
Meaning
signer
Buyer's Stellar address
Wallet that signs the deployment transaction.
approver
Buyer
Approves completed work.
releaseSigner
Buyer
Releases funds to the receiver.
serviceProvider
Seller
Marks milestones complete.
receiver
Seller
Receives released funds.
disputeResolver
Platform wallet (PLATFORM_USER_ID)
Resolves disputes — must differ from the disputer.
platformAddress
Platform wallet
Receives the platform fee.
Every Trustless Work write returns an unsigned XDR; the Orchestrator signs it with the matching user's invisible wallet (paymentProvider.signEscrowTransaction) and submits it with escrowClient.sendTransaction. The platform fee defaults to 5% when the order metadata does not set platformFee.
Deployment type
EscrowClient.createEscrow selects multi-release only when the order has more than one milestone; zero or one milestone deploys a single-release contract. Milestone amounts are validated first — validateMilestoneAmounts throws when the milestone amounts do not sum exactly to the order amount, before any chain call is made.
Order and escrow state transitions
Mermaid
Rendering diagram…
Mermaid
Rendering diagram…
Endpoint
Order before
Order after
Escrow after
POST /orders/{id}/escrow
FUNDS_RESERVED
ESCROW_CREATING → ESCROW_FUNDING
row created as CREATED (or CREATING without a contract ID)
POST /orders/{id}/escrow/fund
ESCROW_FUNDING
IN_PROGRESS
FUNDED (+ fundedAt)
POST /api/v1/orders/:id/escrow
Deploys the escrow contract and links it to the order. OrdersService.createEscrow verifies that both parties are ready with the payment provider, resolves the buyer, seller, and platform Stellar addresses, moves the order to ESCROW_CREATING, deploys the contract, signs the returned XDR with the buyer's wallet, and stores the resulting trustlessContractId on a new escrow row.
Path parameters
Path parameters
Name
Type
Required
Description
id
string
Yes
Order ID with the ord_ prefix, e.g. ord_abc123. The handler reads it via @Param('id').
Preconditions
The order exists — otherwise 404 ORDER_NOT_FOUND.
Order status is FUNDS_RESERVED — otherwise 400 INVALID_STATE (InvalidOrderStateException, "Cannot create escrow order … in state …"). Funds must be reserved first with POST /api/v1/orders/{id}/reserve.
The order has no escrow record — otherwise 409 ESCROW_ALREADY_EXISTS (EscrowAlreadyExistsException).
Buyer and seller both have active payment accounts (paymentProvider.isUserReady) — otherwise a plain Error surfaces as 500 INTERNAL_ERROR with the message "Both buyer and seller must have active payment accounts".
Milestone amounts, when the order has milestones, sum to the order amount (EscrowClient.validateMilestoneAmounts).
What the order contributes
The order is the only input — there is no body. The service maps it into CreateEscrowDto (apps/api/src/providers/trustless-work/dto/escrow.dto.ts) as follows:
Order field
Sent to Trustless Work as
Note
order.id
engagementId
Reference back to the order.
order.amount
amount (USDC, not stroops)
Parsed with parseFloat; the client converts.
order.title
title
Falls back to Escrow for order {id}.
order.description
description
Falls back to a generic string.
order.milestones[]
milestones[]
ref/title/amount become description/amount with receiver: seller_address.
buyer / seller / platform addresses
roles.*
See the role table above.
Response
201 The order with its new escrow relation. Order status is ESCROW_FUNDING and escrow status is CREATED when the contract ID came back from the send; without a contract ID both stay at their previous state (ESCROW_CREATING / CREATING).
Escrow creation response
Escrow creation response fields
Name
Type
Required
Description
data
object
Yes
Standard envelope added by ResponseInterceptor.
data.success
boolean
Yes
Always true on a successful call.
data.data
object
Yes
The order, including escrow, dispute, and milestones (OrderWithRelations).
data.data.id
string
Yes
Order ID.
data.data.status
string
Yes
ESCROW_FUNDING once the contract is deployed.
One of: ESCROW_CREATINGESCROW_FUNDING
data.data.buyerId
string
Yes
Buyer user ID — approver, release signer, and deploy signer.
data.data.sellerId
string
Yes
Seller user ID — service provider and receiver.
data.data.escrow
object
Yes
The escrow row created by this call.
data.data.escrow.id
string
Yes
Escrow ID with the esc_ prefix.
data.data.escrow.trustlessContractId
string | null
No
Deployed Stellar contract address; null when the deployment did not return one.
Nullable
data.data.escrow.status
string
Yes
CREATED after a successful deployment.
One of: CREATINGCREATED
data.data.escrow.amount
string
Yes
Escrow amount as a decimal string with two places, copied from the order.
data.data.escrow.terms
object | null
No
Set to { milestones_required: true } when the order has milestones, otherwise null.
Nullable
data.data.escrow.fundedAt
string | null
No
Null until the escrow is funded.
Nullable
data.data.escrow.releasedAt
string | null
No
Null until the escrow is released.
Nullable
data.data.escrow.refundedAt
string | null
No
Null until the escrow is refunded.
Nullable
meta
object
No
Echoes X-Request-ID and adds a server timestamp.
meta.requestId
string | null
No
Your correlation ID, or undefined when you did not send one.
If any step after the state change fails, the catch block moves the order back to FUNDS_RESERVED, writes an ESCROW_CREATE_FAILED audit entry with result: "FAILURE", and rethrows the original error (orders.service.ts, createEscrow). The error therefore reaches you as a provider failure, not as a state error — read the order again to confirm the rollback landed.
Errors
HTTP
error.code
When
400
INVALID_STATE
Order is not FUNDS_RESERVED (InvalidOrderStateException).
404
ORDER_NOT_FOUND
Unknown order ID.
409
ESCROW_ALREADY_EXISTS
The order already has an escrow record.
500
INTERNAL_ERROR
Buyer or seller has no active payment account, milestone amounts do not sum to the order amount, the Trustless Work deploy call failed, or no unsigned transaction came back. In every case the order is rolled back to FUNDS_RESERVED.
json
{
"error": {
"code": "ESCROW_ALREADY_EXISTS",
"message": "Escrow already exists for order ord_abc123"
}
}
Examples
bash
# No request body: the amount, currency, and milestones come from the order.
# The order must be in FUNDS_RESERVED — call POST /orders/{id}/reserve first.
curl -X POST "http://localhost:4000/api/v1/orders/ord_abc123/escrow" \
-H "Authorization: Bearer ohk_live_xxxxxxxxxxxxxxxxxxxxxxxx" \
-H "X-Request-ID: req_2c8a10"
typescript
import { OfferHubSDK } from "@offerhub/sdk";
const sdk = new OfferHubSDK({
apiUrl: "https://your-orchestrator.example.com",
apiKey: "ohk_live_xxxxxxxxxxxxxxxxxxxxxxxx",
});
// Funds must be reserved first: await sdk.orders.reserve("ord_abc123");
const result = await sdk.orders.createEscrow("ord_abc123");
// The HTTP client returns the parsed JSON body typed as the resource; with the
// current ResponseInterceptor envelope the order sits under data.data.
const order = (result as any).data?.data ?? result;
console.log(order.status, order.escrow?.status); // "ESCROW_FUNDING" "CREATED"
console.log(order.escrow?.trustlessContractId); // Stellar contract address
POST /api/v1/orders/:id/escrow/fund
Moves the buyer's reserved balance into the on-chain contract. OrdersService.fundEscrow deducts order.amount from the buyer's reserved balance, calls the Trustless Work fund endpoint with the buyer's Stellar address, signs and submits the returned XDR with the buyer's wallet, then commits escrow.status = FUNDED with fundedAt and moves the order to IN_PROGRESS in one serializable transaction.
Path parameters
Path parameters
Name
Type
Required
Description
id
string
Yes
Order ID with the ord_ prefix, e.g. ord_abc123.
Preconditions
The order exists and its status is ESCROW_FUNDING — otherwise 400 INVALID_STATE ("Cannot fund escrow order … in state …").
The order has an escrow row — otherwise 500 INTERNAL_ERROR, with the message No escrow found for order {id}.
That escrow row is in status CREATED — otherwise 500 INTERNAL_ERROR, with the message Escrow status must be CREATED, got {status}.
fundEscrow picks the contract type with order.milestones?.length ? "multi-release" : "single-release", while EscrowClient.createEscrow only deploys multi-release when there is more than one milestone. On an order with exactly one milestone the contract is single-release while the fund call addresses the multi-release path. Orders with zero or two or more milestones are unaffected.
If the provider call fails, the catch block reserves the buyer's balance again, moves the order back to FUNDS_RESERVED, writes an ESCROW_FUNDING_FAILED audit entry, and throws EscrowFundingFailedException — so the buyer is never left short.
Errors
HTTP
error.code
When
400
INVALID_STATE
Order is not ESCROW_FUNDING.
400
ESCROW_FUNDING_FAILED
The funding flow failed and was rolled back (EscrowFundingFailedException, a BadRequestException — the shared ERROR_HTTP_STATUS table nominally maps this code to 422).
422
RESERVE_NOT_FOUND
The buyer's reserved balance is below order.amount; details reports requested, reserved, and currency.
500
INTERNAL_ERROR
No escrow row for the order, or the escrow is not in CREATED.
# No request body: the amount is the order amount, debited from the
# buyer's reserved balance. The order must be in ESCROW_FUNDING.
curl -X POST "http://localhost:4000/api/v1/orders/ord_abc123/escrow/fund" \
-H "Authorization: Bearer ohk_live_xxxxxxxxxxxxxxxxxxxxxxxx" \
-H "X-Request-ID: req_2c8a11"
typescript
import { OfferHubSDK } from "@offerhub/sdk";
const sdk = new OfferHubSDK({
apiUrl: "https://your-orchestrator.example.com",
apiKey: "ohk_live_xxxxxxxxxxxxxxxxxxxxxxxx",
});
const result = await sdk.orders.fundEscrow("ord_abc123");
const order = (result as any).data?.data ?? result;
console.log(order.status, order.escrow?.status, order.escrow?.fundedAt);
// "IN_PROGRESS" "FUNDED" "2026-02-10T09:02:11.120Z"
// From here the order can be settled — see the Resolution reference.
await sdk.orders.release("ord_abc123", "All milestones delivered");
End-to-end order
Tip
The four calls that move a marketplace order from created to settled, in order: POST /api/v1/orders → POST /api/v1/orders/{id}/reserve → POST /api/v1/orders/{id}/escrow → POST /api/v1/orders/{id}/escrow/fund → one of release / refund / dispute. The Orders Guide walks the same sequence with SDK calls.
Error codes for this group
Code
HTTP
Endpoints
Meaning
INVALID_STATE
400
both
The order is not in the state this step requires.
ORDER_NOT_FOUND
404
both
Unknown order ID.
ESCROW_ALREADY_EXISTS
409
escrow
The order already has an escrow record.
RESERVE_NOT_FOUND
422
escrow/fund
Reserved balance below the order amount.
ESCROW_FUNDING_FAILED
400
escrow/fund
Funding failed and was rolled back.
INTERNAL_ERROR
500
both
Missing payment accounts, bad milestone totals, wrong escrow status, or a provider failure.
Related
Resolution Endpoints — release, refund, and dispute, the three ways an escrow settles