Errors

The API uses standard HTTP status codes and returns JSON error responses with structured error codes.

HTTP Status Codes

CodeMeaning
200Success
400Bad Request - Invalid parameters
401Unauthorized - Missing or invalid auth
403Forbidden - Insufficient scope or IP blocked
404Not Found - Resource doesn't exist
409Conflict - Duplicate or state conflict
429Too Many Requests - Quota denied or rate-limit authority unavailable
500Internal Server Error
503Service Unavailable - Platform is paused

Error Response Format

All error responses return a JSON object with an error field:

{
  "error": "descriptive error message"
}

Some errors include an additional code field for programmatic handling:

{
  "error": "missing or invalid authentication",
  "code": "UNAUTHORIZED"
}

Global Error Code Catalog

These error codes can be used for programmatic error handling across all endpoints:

CodeHTTP StatusDescription
UNAUTHORIZED401Missing or invalid authentication credentials
API_KEY_UNAUTHORIZED401API key not found, revoked, or expired
FORBIDDEN403Authenticated but not permitted to access resource
INSUFFICIENT_SCOPE403API key lacks the required scope for the endpoint
RATE_LIMITED429Request rate limit exceeded
RATE_LIMIT_DEPENDENCY_UNAVAILABLE429Redis could not authoritatively evaluate the counter; request failed closed
VALIDATION_ERROR400Request body or parameters failed validation
NOT_FOUND404Requested resource does not exist
INTERNAL_ERROR500Unexpected server error

Example Response Bodies

UNAUTHORIZED (401)

{
  "error": "missing or invalid authentication",
  "code": "UNAUTHORIZED"
}

API_KEY_UNAUTHORIZED (401)

{
  "error": "API key is revoked or expired",
  "code": "API_KEY_UNAUTHORIZED"
}

FORBIDDEN (403)

{
  "error": "access denied",
  "code": "FORBIDDEN"
}

INSUFFICIENT_SCOPE (403)

{
  "error": "API key lacks scope: trade:orders",
  "code": "INSUFFICIENT_SCOPE"
}

RATE_LIMITED (429)

{
  "error": "Rate limit exceeded",
  "code": "RATE_LIMITED"
}

RATE_LIMIT_DEPENDENCY_UNAVAILABLE (429)

{
  "error": "Rate limit authority is temporarily unavailable",
  "code": "RATE_LIMIT_DEPENDENCY_UNAVAILABLE"
}

VALIDATION_ERROR (400)

{
  "error": "invalid side: must be BUY or SELL",
  "code": "VALIDATION_ERROR"
}

NOT_FOUND (404)

{
  "error": "order not found",
  "code": "NOT_FOUND"
}

INTERNAL_ERROR (500)

{
  "error": "internal server error",
  "code": "INTERNAL_ERROR"
}

Common Errors by Category

Authentication

ErrorHTTPCause
missing required API key headers401One or more HMAC headers missing
invalid timestamp401Timestamp not a valid Unix epoch
timestamp outside acceptable window401Clock skew > 30 seconds
invalid API key401Key not found or revoked
API key is revoked or expired401Key was revoked by admin
invalid passphrase401Passphrase doesn't match
invalid signature401HMAC signature verification failed

Orders

ErrorHTTPCause
platform is paused503Trading is temporarily suspended
market not active400Market is not in active status
invalid side400Side must be BUY or SELL
invalid maker_amount400Non-positive or non-numeric amount
order already expired400Expiration timestamp is in the past
FOK order could not be fully filled400No complete match available (FOK)
FAK order had no immediate fills400No matching orders on the book (FAK)
post-only order would cross the book400Order would take liquidity (post-only)
order cannot be cancelled409Order already filled/cancelled/expired
order not found404Order hash doesn't exist or wrong user

Time-in-Force

ErrorHTTPCause
invalid time_in_force400TIF value not GTC/GTD/FOK/FAK
GTD orders require an expiration time400Missing expiration for GTD

Rate Limits

ErrorHTTPCause
RATE_LIMITED429Too many requests in the current window
RATE_LIMIT_DEPENDENCY_UNAVAILABLE429Redis authority unavailable; quota unknown

Batch Operations

ErrorHTTPCause
too many orders (max 50)400Batch exceeds 50 orders
no orders provided400Empty orders array
no order hashes provided400Empty cancel hashes array

Handling Errors

import requests
import time

resp = requests.post(f"{BASE_URL}/orders", json=order, headers=headers)

if resp.status_code == 429:
    error = resp.json()
    if error.get("code") == "RATE_LIMIT_DEPENDENCY_UNAVAILABLE":
        # Infrastructure retry. This is not evidence of exhausted quota.
        wait_seconds = int(resp.headers.get("Retry-After", 1))
    else:
        reset_time = int(resp.headers.get("X-RateLimit-Reset", 0))
        wait_seconds = max(1, reset_time - int(time.time()))
    time.sleep(wait_seconds)
    # Retry...

elif resp.status_code == 401:
    error = resp.json()
    code = error.get("code", "")
    if code == "API_KEY_UNAUTHORIZED":
        print("API key revoked - regenerate key")
    else:
        print("Auth error - check credentials")

elif resp.status_code == 503:
    # Platform paused - stop trading
    print("Platform is paused, stopping")

elif resp.status_code >= 400:
    error = resp.json().get("error", "unknown error")
    print(f"Error {resp.status_code}: {error}")
const resp = await fetch(`${BASE_URL}/orders`, {
  method: 'POST',
  headers: { ...authHeaders, 'Content-Type': 'application/json' },
  body: JSON.stringify(order),
});

if (resp.status === 429) {
  const { code } = await resp.json();
  const waitMs = code === 'RATE_LIMIT_DEPENDENCY_UNAVAILABLE'
    ? parseInt(resp.headers.get('Retry-After') ?? '1') * 1000
    : Math.max(
        1000,
        (parseInt(resp.headers.get('X-RateLimit-Reset') ?? '0') -
          Math.floor(Date.now() / 1000)) * 1000,
      );
  await new Promise((r) => setTimeout(r, waitMs));
  // Retry...
} else if (resp.status === 401) {
  const { code } = await resp.json();
  if (code === 'API_KEY_UNAUTHORIZED') {
    console.error('API key revoked - regenerate key');
  }
} else if (!resp.ok) {
  const { error } = await resp.json();
  console.error(`Error ${resp.status}: ${error}`);
}

Idempotency

There are two independent mechanisms. Retrying an order safely requires the second one.

Order-hash uniqueness (automatic)

polygon_orders.order_hash is unique, so the same signed order can never be stored twice. Re-submitting one returns 409 Conflict — it does not return the original order.

Note that the response code is the generic ORDER_PLACEMENT_FAILED; the error message is duplicate order. Match on the 409 status together with that message, not on a dedicated code.

This protects the exchange's state, but on its own it is not enough for a client. If your request reached us and the response was lost, your retry sends the identical signed body and receives a 409. You will record the order as failed while it is in fact resting on the book. Re-signing and re-sending produces a different salt, a different hash, and therefore genuinely new exposure.

Idempotency-Key header (recommended for order placement)

Send one on POST /v1/orders, POST /v1/orders/cancel-replace, PUT /v1/orders/:hash/amend (re-signed form only — an amend without a new signed order is not idempotency-guarded), POST /v1/orders/batch and DELETE /v1/orders/batch. A repeat of the same key replays the original stored response — including the original success — so a retry after a lost response is safe.

24 hours is the replay window you can rely on. A stored response is not discarded the moment it expires - a later retry still re-reads it rather than re-placing the order - and the row is deleted 7 days past expiry, so the real lifetime is 8 days.

All five routes take the same key format and return the same codes. The batch pair used to diverge — a non-UUID key was accepted, and the two 409s carried IDEMPOTENCY_CONFLICT / IDEMPOTENCY_IN_PROGRESS instead. They no longer do.

single-order routesPOST / DELETE /v1/orders/batch
Header required?No — omitting it places the order unguardedNo
Key formatMust be a UUID. Anything else is rejected with 400 IDEMPOTENCY_KEY_MALFORMEDSame
Same key, different payload409 IDEMPOTENCY_KEY_PAYLOAD_MISMATCHSame
Still in flight409 IDEMPOTENCY_KEY_IN_PROGRESSSame
NOT_MATCHING_LEADER, PLATFORM_PAUSED, SETTLEMENT_UNHEALTHYNot stored. The claim is released, so the same key re-runsStored and replayed. The same key returns that refusal until it expires

Everything else — every other status, any other 5xx included — is terminal for the key on all five routes. An ambiguous failure may have been raised after a partial write, and replaying it is the whole point of the header.

The three-refusal exception is narrow on purpose, and the batch routes are excluded for a reason that is not "the refusal came after a partial execution" — a leader refusal is taken before anything runs on the batch routes too. It is that on a batch the server cannot tell the two apart: SETTLEMENT_UNHEALTHY is evaluated per order and can flip mid-request, so order 0 can be persisted and order 1 refused in the same call. Releasing that claim would re-place order 0 on retry, so a batch fails safe and stores whatever the first attempt returned.

Derive the key from the order's content rather than generating a random one per attempt — a random key regenerated on retry defeats the whole mechanism. A UUID v5 over the order signature works well: stable across retries of the same order, distinct for a genuinely new one. On the batch routes fold an attempt counter into that derivation and advance it only when you have read a stored refusal and decided to re-run: the stored reply is a record of that attempt, not a verdict on the orders, and re-running it is a new attempt that needs its own key.

const res = await fetch("https://api.4rho.com/v1/orders", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "Idempotency-Key": uuidv5(signedOrder.signature, NAMESPACE), // NOT uuidv4()
    ...authHeaders,
  },
  body: JSON.stringify(signedOrder),
});