Error Handling
How the V3 gateway reports errors, and where asynchronous trade failures surface.
The error body
There is no single error shape. Branch on the HTTP status first, then on errorCode when the body carries one.
Business errors, raised by the gateway's own logic, carry errorCode:
{
"message": "Validation error",
"errorCode": "VALIDATION_ERROR",
"details": [
{ "path": "legs.0.weight", "message": "Invalid input: expected number, received string", "code": "invalid_type" }
],
"timestamp": "2026-01-01T00:00:00.000Z"
}Authentication failures, guard failures, and 404s from a route handler carry the framework's shape instead. There is no errorCode and no timestamp:
{
"message": "Missing required header: x-trade-account-id",
"error": "Bad Request",
"statusCode": 400
}Both shapes always carry message, so message is the only field you can read without checking which shape you have. Never branch on its text.
For a body-validation failure, message is always the literal string "Validation error", and details is an array of { path, message, code }, one entry per rejected field. path is dotted, so a bad leg reads legs.0.weight. Hand-written business checks use the same envelope with an object in details instead.
The TypeScript SDK throws a FetchError for any response with status ≥ 300:
try {
await sdk.core.trade.open(body);
} catch (err) {
// FetchError { message, statusText, status, errorCode }
}errorCode is read off the response envelope, so it is undefined for the framework shape. message falls back to the HTTP status text when the body has none.
In session auth mode the SDK retries a 401 once through POST /auth/session/refresh, and only throws FetchError('Session expired. Please log in again.', 'Unauthorized', 401) when the refresh also fails, firing your onSessionExpired callback. bearer and apikey modes never retry.
Status codes
| Status | Meaning |
|---|---|
400 | Validation error, malformed body, invalid basket configuration, a missing or non-UUID x-trade-account-id, or an unknown or inactive clientId. |
401 | Missing or invalid credentials: no token, an expired or revoked token, or an unknown or expired x-api-key. |
403 | The credential is valid but not allowed to do this: no write access, or an auth method the route refuses. |
404 | Resource not found (position, trigger, schedule, ladder, auto-rebalance config), or an x-trade-account-id that is not yours. |
409 | The resource is busy, or the request would do nothing. |
A trade account belonging to another user returns 404, not 403. That is deliberate: a foreign account and a nonexistent one are indistinguishable, so you cannot probe for account IDs. Do not treat 404 on a trading call as "the position is gone" without checking the trade account header first.
There is no 429 and no Retry-After. There is no 503 maintenance mode.
Authentication failures
All of these use the framework shape, so match on the status and the exact message.
| Status | message | Cause |
|---|---|---|
401 | Missing authorization token | No bearer token and no session cookie. |
401 | Invalid or expired access token | The token failed signature or expiry verification. |
401 | Invalid API key | The x-api-key value is unknown or revoked. |
401 | API key has expired | The key passed its expiresAt. |
401 | Token has been revoked | The OAuth grant behind the token was revoked. |
401 | Token revocation status unavailable | The revocation check could not run. Retry. |
403 | API key does not have write access | The key's scope is read. Mint a read_write key. |
403 | OAuth grant does not have write access | The grant lacks the write scope. |
403 | Auth method '<method>' is not permitted on this route | For example, using an API key on a session-only route such as /api-keys. |
x-api-key takes precedence: when both an API key header and a bearer token are present, the key decides the principal and the token is ignored.
Error codes
errorCode is stable, so branch on it rather than on the message text:
errorCode | Status | Meaning |
|---|---|---|
VALIDATION_ERROR | 400 | The request body or query failed validation. Also the fallback for any workflow failure that names no other code. |
RESOURCE_NOT_FOUND | 404 | The named position, trigger, schedule, ladder, or auto-rebalance config does not exist on this account. |
RESOURCE_BUSY | 409 | Another action on that resource is still running. Retry after it settles. |
NOOP_ERROR | 409 | The request would change nothing, such as cancelling an already-cancelled order, or closing an account with nothing open. |
DATABASE_ERROR, CONFIGURATION_ERROR | 500 | A gateway-side failure. Retry, then contact Pear. |
NOT_IMPLEMENTED_ERROR | 501 | The venue does not support this action. |
Auto-rebalance adds its own codes. See Auto Rebalance.
errorCode | Status | Meaning |
|---|---|---|
AUTO_REBALANCE_POSITION_NOT_FOUND | 404 | The position does not exist, or is not yours. |
AUTO_REBALANCE_CONFIG_NOT_FOUND | 404 | The auto-rebalance configuration does not exist. |
AUTO_REBALANCE_POSITION_NOT_OPEN | 409 | Only an OPEN position can carry a configuration. |
AUTO_REBALANCE_ACTIVE_CONFIG_EXISTS | 409 | The position already has an active configuration. Cancel or replace it. |
AUTO_REBALANCE_INVALID_LIVE_BETA_EXPOSURE | 400 | LIVE_BETA needs exactly one long and one short non-zero leg. |
Syncing venue activity adds four more. See Syncing Venue Activity.
errorCode | Status | Meaning |
|---|---|---|
SYNC_CREATE_EXECUTION_IN_FLIGHT | 409 | A Pear execution on that account is still running. Wait for it to settle. |
SYNC_CREATE_EXECUTION_JUST_SETTLED | 409 | An execution settled too recently for the fill to be attributed safely. |
SYNC_CREATE_FILLS_TOO_RECENT | 409 | The fill is younger than the minimum age, returned in details. |
SYNC_CREATE_CLOID_ALREADY_PROVISIONAL | 409 | That client order ID already has a provisional fill. |
RESOURCE_BUSY is the one to handle explicitly in a bot. It means your previous action on that position is still in flight, not that the action was rejected. The four SYNC_CREATE_* conflicts behave the same way: retry after a delay, do not treat them as a rejection.
Asynchronous trade outcomes
POST /trade/open and the other execution endpoints are asynchronous. 202 Accepted is an acknowledgement, and it returns an execution whose status starts at IDLE. Success or failure arrives over the WebSocket:
execution.completed, the trade filled.position.createdorposition.updatedfollows.execution.failed, the trade failed.
The event does not carry the reason. Like every private event, it carries only data.entityId, the execution ID. Read the reason with GET /executions/{id}: the execution's error field holds the failure message, and each order carries its own error and errorCode.
IDLE → FAILED is the common failure, and the useful one. The execution stays IDLE for the whole preparation phase, and only becomes ACTIVE once orders reach the venue. So a FAILED execution that never reached ACTIVE placed no orders, and your account is untouched. Sizing, margin, credential, and configuration failures all land here.
An execution that did reach ACTIVE may leave partial state behind. Each order inside it carries its own status: PENDING, PARTIAL, FILLED, CANCELLED, or REJECTED. A basket can therefore be partly filled while the execution as a whole has failed.
Reconcile, do not retry
Do not infer "no order was placed" from an ambiguous HTTP response, a timeout, or a dropped socket. Read the execution first:
curl "https://pro-gateway.pearprotocol.io/executions/$EXECUTION_ID" \
-H "x-api-key: $PEAR_API_KEY" \
-H "x-trade-account-id: $TRADE_ACCOUNT_ID"{
"execution": {
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "FAILED",
"error": "Insufficient margin",
"orders": [
{ "id": "8d2e…", "symbol": "0", "side": "BUY", "status": "FILLED", "filledQuantity": "0.0052" },
{ "id": "1c47…", "symbol": "1", "side": "SELL", "status": "REJECTED", "errorCode": "MARGIN" }
]
}
}That example leaves a one-sided position: the BTC leg filled and the ETH leg did not. Check GET /positions before you place anything else.
Fetch the execution by ID. Do not look for it in the list. GET /executions has two blind spots that GET /executions/{id} does not:
- The
statusfilter accepts onlyACTIVE,COMPLETED, andCANCELLED. There is no way to ask forIDLE, andCOMPLETEDsilently returnsFAILEDexecutions as well. Omitstatusto get every status back. - The list excludes schedule- and trigger-originated executions by default. A TWAP or TP/SL execution is invisible unless you pass
includeSchedule=trueorincludeTriggers=true.