Per-endpoint reference for the OFFER-HUB order resolution API — release, refund, and dispute — with real DTO fields, Trustless Work signer roles, valid state transitions, error codes, and cURL plus TypeScript SDK examples.
The three resolution endpoints decide how escrowed funds leave a funded order: released to the seller, refunded to the buyer, or frozen while a dispute is resolved. All three live in a single controller, ResolutionController (apps/api/src/modules/resolution/resolution.controller.ts), mounted at @Controller('orders/:orderId/resolution'). Combined with the global /api/v1 prefix set in apps/api/src/main.ts, the full paths are:
Method
Path
Success status
Controller handler
POST
/api/v1/orders/{orderId}/resolution/release
200 OK
ResolutionController.requestRelease
POST
/api/v1/orders/{orderId}/resolution/refund
200 OK
ResolutionController.requestRefund
POST
/api/v1/orders/{orderId}/resolution/dispute
201 Created
ResolutionController.openDispute
Note
This page is written from the Orchestrator source, not from the API contract: apps/api/src/modules/resolution/{resolution.controller.ts,resolution.service.ts,dto/*.ts,exceptions/resolution.exceptions.ts} and apps/api/src/modules/orders/orders.service.ts. Escrow creation and funding are documented separately in Escrow Endpoints. The follow-up endpoints that close a dispute — POST /api/v1/disputes/{disputeId}/assign and POST /api/v1/disputes/{disputeId}/resolve — live in DisputesController (apps/api/src/modules/disputes/disputes.controller.ts) and are covered in Disputes Guide.
Common behavior
Authentication and scope
ResolutionController declares no guards and no scopes — there is no @UseGuards(ApiKeyGuard, ScopeGuard) and no @Scopes(...) decorator anywhere in apps/api/src/modules/resolution/resolution.controller.ts (compare apps/api/src/modules/balance/balance.controller.ts, which does declare both). These routes are therefore not authenticated and not scope-gated in the current Orchestrator.
Aspect
Value on these routes
Source
Authentication
None enforced (no ApiKeyGuard)
resolution.controller.ts
Required scope
None (no ScopeGuard / @Scopes)
resolution.controller.ts
Rate limiting
Yes — global RateLimitGuard via APP_GUARD
apps/api/src/app.module.ts
Correlation
X-Request-ID echoed in meta.requestId when sent
CorrelationIdMiddleware, ResponseInterceptor
Warning
The examples below still send Authorization: Bearer … because that is how every other reference in these docs is written — and you should send it, since a future release may add ApiKeyGuard to this controller. Today the header is simply ignored by these three routes.
Request validation
A global ValidationPipe runs with whitelist: true, forbidNonWhitelisted: true, and transform: true (apps/api/src/main.ts). Two consequences apply to every DTO on this page:
Unknown body properties are rejected with 400 VALIDATION_ERROR instead of being silently dropped.
evidence is validated as an array of any values (@IsArray() with no @IsString({ each: true }) in open-dispute.dto.ts), so send an array of URL strings — that is what the schema, the docs, and the SDK types (evidence?: string[] in packages/sdk/src/types/index.ts) assume.
Response envelope
ResponseInterceptor (apps/api/src/common/interceptors/response.interceptor.ts) wraps every handler result. Its isAlreadyWrapped check only skips wrapping when the payload already has a meta key or exactly one key, and the controller returns { success, data } — two keys, no meta — so the payload is wrapped. Successful calls therefore look like this on the wire:
Errors use the single-level envelope produced by GlobalExceptionFilter (apps/api/src/common/filters/global-exception.filter.ts): { "error": { "code", "message", "details?" } }.
Idempotency
Danger
These three endpoints do not support the Idempotency-Key header. The idempotency guard, interceptor, and TTL decorator exist (apps/api/src/common/guards/idempotency.guard.ts, apps/api/src/common/interceptors/idempotency.interceptor.ts, apps/api/src/common/decorators/idempotency-ttl.decorator.ts) but are never attached to ResolutionController or OrdersController, so a key sent here is ignored. Retries are made safe by the state machine instead: once an order leaves IN_PROGRESS, a replayed call fails with 400 INVALID_STATE and moves no funds. See Idempotency for the endpoints that do honor the header.
Completion is synchronous
Trustless Work sends no webhooks to these flows, so ResolutionService finishes each operation inline: requestRelease calls confirmRelease (resolution.service.ts) and requestRefund calls confirmRefund before returning. The order you get back is already CLOSED — there is no pending state to poll. If a provider call fails after the order was moved to RELEASE_REQUESTED / REFUND_REQUESTED, the service rolls the order back to IN_PROGRESS and rethrows, so a retry is safe.
Trustless Work signer roles
Each contract is deployed with three Stellar roles (apps/api/src/providers/trustless-work/types/trustless-work.types.ts): approver (the buyer), serviceProvider (the seller), and disputeResolver (the platform wallet configured as PLATFORM_USER_ID). Every write to Trustless Work returns an unsigned XDR that the Orchestrator signs with the matching user's invisible wallet via paymentProvider.signEscrowTransaction before submitting it to Stellar.
No on-chain call — the internal state machine moves only
—
—
Note
Refund and split resolution are two-step on purpose: the Trustless Work contracts forbid the disputeResolver from also being the disputer, so the buyer disputes and the platform wallet resolves (resolution.service.ts, executeFullRefund / executeSplitResolution).
Valid state transitions
Release, refund, and dispute all start from IN_PROGRESS on an order whose escrow is FUNDED, and each one ends at CLOSED:
Mermaid
Rendering diagram…
The escrow record follows its own machine — FUNDED becomes RELEASED, REFUNDED, or DISPUTED (packages/shared/src/enums/escrow-status.enum.ts):
Mermaid
Rendering diagram…
Endpoint
Order before
Order after
Escrow after
release
IN_PROGRESS
RELEASE_REQUESTED → RELEASED → CLOSED
RELEASED (+ releasedAt)
refund
IN_PROGRESS
REFUND_REQUESTED → REFUNDED → CLOSED
REFUNDED (+ refundedAt)
dispute
IN_PROGRESS
DISPUTED
DISPUTED
POST /api/v1/orders/:orderId/resolution/release
Releases the escrowed funds to the seller. ResolutionService.requestRelease validates the order, moves it to RELEASE_REQUESTED, then executes the three Trustless Work calls above before confirming the release and crediting the seller's internal balance (BalanceService.release).
Path parameters
Path parameters
Name
Type
Required
Description
orderId
string
Yes
Order ID with the ord_ prefix, e.g. ord_abc123. Read from the path, not the body.
Request body
The body is optional — @Body() dto?: RequestReleaseDto — but when present it must validate against RequestReleaseDto (apps/api/src/modules/resolution/dto/request-release.dto.ts).
Release request body
Name
Type
Required
Description
reason
string
No
Free-text reason recorded in the audit log as the after-payload of the RELEASE_REQUESTED entry. Omit the body entirely if you have no reason to record.
Preconditions
Checked in ResolutionService.requestRelease before any state change:
The order exists, and has an escrow record — otherwise 400 INVALID_STATE ("Order must have escrow").
Order status is IN_PROGRESS — otherwise 400 INVALID_STATE.
No active dispute — see the note in Known behavior below.
Every milestone, if the order has any, is COMPLETED — otherwise 400 INVALID_STATE naming the incomplete milestoneRef values.
Escrow status is FUNDED — otherwise 400 INVALID_STATE.
OrderStateMachine.assertTransition(IN_PROGRESS → RELEASE_REQUESTED) (packages/shared/src/utils, transition table in packages/shared/src/enums/order-status.enum.ts).
Response
200 The updated order, already CLOSED, with its escrow set to RELEASED.
Release response
Release 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 its escrow, dispute, and milestones relations (OrderWithRelations).
data.data.id
string
Yes
Order ID.
data.data.status
string
Yes
Order status after the release. CLOSED when the flow completes.
One of: RELEASE_REQUESTEDRELEASEDCLOSED
data.data.amount
string
Yes
Order amount as a decimal string with two places.
data.data.currency
string
Yes
Order currency, e.g. USD.
data.data.buyerId
string
Yes
Buyer user ID — the approver and release signer.
data.data.sellerId
string
Yes
Seller user ID — the credited party and milestone signer.
data.data.escrow
object | null
Yes
Escrow record for the order.
Nullable
data.data.escrow.status
string
Yes
Escrow status; RELEASED once confirmRelease commits.
One of: CREATINGCREATEDFUNDINGFUNDEDRELEASEDREFUNDEDDISPUTED
data.data.escrow.releasedAt
string | null
No
ISO 8601 timestamp of the release.
Nullable
data.data.milestones
array
Yes
Milestones included in the response; all must be COMPLETED before release.
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.
Unknown or malformed body property (global ValidationPipe).
400
INVALID_STATE
Order not IN_PROGRESS, escrow missing or not FUNDED, or a milestone is not COMPLETED. Raised as InvalidResolutionStateException (exceptions/resolution.exceptions.ts).
404
ORDER_NOT_FOUND
No order with that ID (OrderNotFoundException, apps/api/src/modules/orders/exceptions/orders.exceptions.ts).
409
DISPUTE_ALREADY_OPEN
A dispute that is not RESOLVED blocks the release (ActiveDisputeException).
500
INTERNAL_ERROR
A Trustless Work call failed. The order is rolled back to IN_PROGRESS first.
json
{
"error": {
"code": "INVALID_STATE",
"message": "Order ord_abc123 is in state ORDER_CREATED, but IN_PROGRESS is required for this operation"
}
}
Examples
bash
curl -X POST "http://localhost:4000/api/v1/orders/ord_abc123/resolution/release" \
-H "Authorization: Bearer ohk_live_xxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-H "X-Request-ID: req_7f3a1c" \
-d '{
"reason": "All milestones delivered and approved"
}'
typescript
import { OfferHubSDK } from "@offerhub/sdk";
const sdk = new OfferHubSDK({
apiUrl: "https://your-orchestrator.example.com",
apiKey: "ohk_live_xxxxxxxxxxxxxxxxxxxxxxxx",
});
// reason is optional; sdk.orders.release(orderId, reason?)
const result = await sdk.orders.release("ord_abc123", "All milestones delivered");
// The HTTP client returns the parsed JSON body typed as the resource. With the
// current ResponseInterceptor envelope that body is { data: { success, data }, meta },
// so the order object sits one level down.
const order = (result as any).data?.data ?? result;
console.log(order.status); // "CLOSED"
POST /api/v1/orders/:orderId/resolution/refund
Returns the escrowed funds to the buyer. Because Trustless Work has no refund endpoint, ResolutionService.requestRefund disputes the contract and then resolves it with a 100% distribution to the buyer (disputeEscrow → resolveDisputeForRefund), before crediting the buyer's available balance (BalanceService.creditAvailable).
Path parameters
Path parameters
Name
Type
Required
Description
orderId
string
Yes
Order ID with the ord_ prefix, e.g. ord_abc123.
Request body
The body is required here — @Body() dto: RequestRefundDto (no ?), validated against apps/api/src/modules/resolution/dto/request-refund.dto.ts.
Refund request body
Name
Type
Required
Description
reason
string
Yes
Non-empty reason for the refund, stored in the audit log. Validation is @IsString() + @IsNotEmpty(), so an empty string is rejected.
Preconditions
Identical to release, minus the milestone check: order exists, has an escrow, is IN_PROGRESS, has no active dispute, escrow is FUNDED, and REFUND_REQUESTED is reachable from IN_PROGRESS.
Response
200 The updated order, already CLOSED, with escrow REFUNDED.
Refund response
Refund 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 its escrow, dispute, and milestones relations.
data.data.id
string
Yes
Order ID.
data.data.status
string
Yes
Order status after the refund. CLOSED when the flow completes.
One of: REFUND_REQUESTEDREFUNDEDCLOSED
data.data.escrow.status
string
Yes
Escrow status; REFUNDED once confirmRefund commits.
One of: FUNDEDREFUNDED
data.data.escrow.refundedAt
string | null
No
ISO 8601 timestamp of the refund.
Nullable
data.data.milestones
array
Yes
Milestones included in the response. Refund does not require them to be COMPLETED.
reason missing, not a string, empty, or an unknown body property.
400
INVALID_STATE
Order not IN_PROGRESS, escrow missing or not FUNDED.
404
ORDER_NOT_FOUND
No order with that ID.
409
DISPUTE_ALREADY_OPEN
A dispute that is not RESOLVED blocks the refund.
500
INTERNAL_ERROR
A Trustless Work call failed; the order is rolled back to IN_PROGRESS.
json
{
"error": {
"code": "VALIDATION_ERROR",
"message": "General validation error",
"details": {
"validationErrors": ["reason should not be empty"]
}
}
}
Examples
bash
curl -X POST "http://localhost:4000/api/v1/orders/ord_abc123/resolution/refund" \
-H "Authorization: Bearer ohk_live_xxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"reason": "Seller never delivered the agreed scope"
}'
typescript
import { OfferHubSDK } from "@offerhub/sdk";
const sdk = new OfferHubSDK({
apiUrl: "https://your-orchestrator.example.com",
apiKey: "ohk_live_xxxxxxxxxxxxxxxxxxxxxxxx",
});
// reason is required by RequestRefundDto
const result = await sdk.orders.refund(
"ord_abc123",
"Seller never delivered the agreed scope",
);
const order = (result as any).data?.data ?? result;
console.log(order.status, order.escrow?.status); // "CLOSED" "REFUNDED"
POST /api/v1/orders/:orderId/resolution/dispute
Freezes the escrow and opens a dispute. ResolutionService.openDispute creates the Dispute row, moves the order to DISPUTED, and marks the escrow DISPUTED in one serializable transaction. No on-chain transaction is sent — the Trustless Work contract is only touched later, when the dispute is resolved.
Path parameters
Path parameters
Name
Type
Required
Description
orderId
string
Yes
Order ID with the ord_ prefix. The service reads the order from this path value; the body's orderId is only validated.
Request body
Validated against OpenDisputeDto (apps/api/src/modules/resolution/dto/open-dispute.dto.ts).
Dispute request body
Name
Type
Required
Description
orderId
string
Yes
Required by the DTO (@IsString + @IsNotEmpty) even though the service uses the path parameter. Send the same value as in the path — a mismatched body value is accepted by validation but ignored, because the transaction writes the path orderId.
openedBy
string
Yes
Which party opened the dispute. Enum DisputeOpenedBy.
One of: BUYERSELLER
reason
string
Yes
Dispute category. Enum DisputeReason.
One of: NOT_DELIVEREDQUALITY_ISSUEOTHER
evidence
string[]
No
Evidence references, typically URLs. Stored as JSON on the dispute record. Defaults to JSON null when omitted.
Warning
orderId in the body is mandatory: the global ValidationPipe runs with whitelist: true and the DTO field is not optional, so omitting it fails with 400 VALIDATION_ERROR before the handler runs. The TypeScript SDK's disputes.open(orderId, data) sends only { openedBy, reason, evidence } (packages/sdk/src/resources/disputes.ts) and therefore does not include it — with a stock SDK client this request returns 400 VALIDATION_ERROR. Add orderId to the body object you pass in.
Preconditions
Checked in ResolutionService.openDispute: the order exists, its status is IN_PROGRESS, and no unresolved dispute exists for it (validateCanOpenDispute → DisputeAlreadyExistsException). Note that resolveDispute requires the dispute to be UNDER_REVIEW, so an OPEN dispute must be assigned via POST /api/v1/disputes/{disputeId}/assign before it can be resolved.
Response
201 The created dispute, with its order relation (the relation include in the create transaction), status: "OPEN", and the order moved to DISPUTED.
Dispute response
Dispute 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 created dispute (DisputeWithRelations).
data.data.id
string
Yes
Dispute ID with the dsp_ prefix, generated by generateDisputeId().
data.data.orderId
string
Yes
Order the dispute belongs to.
data.data.openedBy
string
Yes
Who opened the dispute.
One of: BUYERSELLER
data.data.reason
string
Yes
Dispute category.
One of: NOT_DELIVEREDQUALITY_ISSUEOTHER
data.data.evidence
string[] | null
No
Evidence references as stored, or null when none were sent.
Nullable
data.data.status
string
Yes
Dispute status — OPEN right after creation.
One of: OPENUNDER_REVIEWRESOLVED
data.data.resolutionDecision
string | null
No
Set when the dispute is resolved: FULL_RELEASE, FULL_REFUND, or SPLIT.
One of: FULL_RELEASEFULL_REFUNDSPLITNullable
data.data.decisionNote
string | null
No
Resolver note; null until the dispute is resolved.
Nullable
data.data.order
object
No
The order with its escrow, dispute, and milestones relations.
data.data.order.status
string
Yes
DISPUTED — set in the same transaction as the dispute row.
import { OfferHubSDK } from "@offerhub/sdk";
const sdk = new OfferHubSDK({
apiUrl: "https://your-orchestrator.example.com",
apiKey: "ohk_live_xxxxxxxxxxxxxxxxxxxxxxxx",
});
// OpenDisputeDto marks orderId as required, so include it in the body object
// even though the endpoint reads the order from the path.
const result = await sdk.disputes.open("ord_abc123", {
orderId: "ord_abc123",
openedBy: "BUYER",
reason: "QUALITY_ISSUE",
evidence: ["https://files.example.com/revision-3.png"],
});
const dispute = (result as any).data?.data ?? result;
console.log(dispute.id, dispute.status); // "dsp_01JKQ7" "OPEN"
// The dispute must be assigned before it can be resolved.
await sdk.disputes.assign(dispute.id, { assignedTo: "agent_support01" });
Error codes for this group
Every code below is defined once in packages/shared/src/constants/error-codes.ts and rendered by GlobalExceptionFilter.
Code
HTTP
Endpoints
Meaning
VALIDATION_ERROR
400
all
DTO validation failed; details.validationErrors lists the messages.
INVALID_STATE
400
all
Order status, escrow status, or dispute state does not allow the operation.
ORDER_NOT_FOUND
404
all
Unknown orderId.
DISPUTE_ALREADY_OPEN
409
release, refund, dispute
A dispute that is not RESOLVED blocks the operation.
INTERNAL_ERROR
500
all
Provider or database failure; provider-driven rollbacks are described per endpoint.
Note
INVALID_STATE is raised as a BadRequestException (HTTP 400) by InvalidResolutionStateException and InvalidDisputeStateException, even though the shared catalog's ERROR_HTTP_STATUS table maps INVALID_STATE to 409. Trust the HTTP status you receive, not the catalog table, and branch on error.code.
Known behavior
Warning
The "no active dispute" precondition on release and refund does not currently trigger. ResolutionService.validateNoActiveDispute inspects order.dispute, but OrdersService.getOrder loads only escrow and milestones (apps/api/src/modules/orders/orders.service.ts), so the field is always undefined and ActiveDisputeException is never raised from those two endpoints. A dispute opened on an order still blocks both operations in practice, because openDispute moves the order to DISPUTED and the IN_PROGRESS check then rejects the call with 400 INVALID_STATE.
Related
Escrow Endpoints — POST /api/v1/orders/{id}/escrow and POST /api/v1/orders/{id}/escrow/fund, the flows that must run before any resolution call