PearPear
API Integration

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

StatusMeaning
400Validation error, malformed body, invalid basket configuration, a missing or non-UUID x-trade-account-id, or an unknown or inactive clientId.
401Missing or invalid credentials: no token, an expired or revoked token, or an unknown or expired x-api-key.
403The credential is valid but not allowed to do this: no write access, or an auth method the route refuses.
404Resource not found (position, trigger, schedule, ladder, auto-rebalance config), or an x-trade-account-id that is not yours.
409The 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.

StatusmessageCause
401Missing authorization tokenNo bearer token and no session cookie.
401Invalid or expired access tokenThe token failed signature or expiry verification.
401Invalid API keyThe x-api-key value is unknown or revoked.
401API key has expiredThe key passed its expiresAt.
401Token has been revokedThe OAuth grant behind the token was revoked.
401Token revocation status unavailableThe revocation check could not run. Retry.
403API key does not have write accessThe key's scope is read. Mint a read_write key.
403OAuth grant does not have write accessThe grant lacks the write scope.
403Auth method '<method>' is not permitted on this routeFor 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:

errorCodeStatusMeaning
VALIDATION_ERROR400The request body or query failed validation. Also the fallback for any workflow failure that names no other code.
RESOURCE_NOT_FOUND404The named position, trigger, schedule, ladder, or auto-rebalance config does not exist on this account.
RESOURCE_BUSY409Another action on that resource is still running. Retry after it settles.
NOOP_ERROR409The request would change nothing, such as cancelling an already-cancelled order, or closing an account with nothing open.
DATABASE_ERROR, CONFIGURATION_ERROR500A gateway-side failure. Retry, then contact Pear.
NOT_IMPLEMENTED_ERROR501The venue does not support this action.

Auto-rebalance adds its own codes. See Auto Rebalance.

errorCodeStatusMeaning
AUTO_REBALANCE_POSITION_NOT_FOUND404The position does not exist, or is not yours.
AUTO_REBALANCE_CONFIG_NOT_FOUND404The auto-rebalance configuration does not exist.
AUTO_REBALANCE_POSITION_NOT_OPEN409Only an OPEN position can carry a configuration.
AUTO_REBALANCE_ACTIVE_CONFIG_EXISTS409The position already has an active configuration. Cancel or replace it.
AUTO_REBALANCE_INVALID_LIVE_BETA_EXPOSURE400LIVE_BETA needs exactly one long and one short non-zero leg.

Syncing venue activity adds four more. See Syncing Venue Activity.

errorCodeStatusMeaning
SYNC_CREATE_EXECUTION_IN_FLIGHT409A Pear execution on that account is still running. Wait for it to settle.
SYNC_CREATE_EXECUTION_JUST_SETTLED409An execution settled too recently for the fill to be attributed safely.
SYNC_CREATE_FILLS_TOO_RECENT409The fill is younger than the minimum age, returned in details.
SYNC_CREATE_CLOID_ALREADY_PROVISIONAL409That 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.created or position.updated follows.
  • 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 status filter accepts only ACTIVE, COMPLETED, and CANCELLED. There is no way to ask for IDLE, and COMPLETED silently returns FAILED executions as well. Omit status to 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=true or includeTriggers=true.

On this page