Errors
The API uses standard HTTP status codes and returns JSON error responses with structured error codes.
HTTP Status Codes
| Code | Meaning |
|---|---|
| 200 | Success |
| 400 | Bad Request - Invalid parameters |
| 401 | Unauthorized - Missing or invalid auth |
| 403 | Forbidden - Insufficient scope or IP blocked |
| 404 | Not Found - Resource doesn't exist |
| 409 | Conflict - Duplicate or state conflict |
| 429 | Too Many Requests - Quota denied or rate-limit authority unavailable |
| 500 | Internal Server Error |
| 503 | Service 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:
| Code | HTTP Status | Description |
|---|---|---|
UNAUTHORIZED | 401 | Missing or invalid authentication credentials |
API_KEY_UNAUTHORIZED | 401 | API key not found, revoked, or expired |
FORBIDDEN | 403 | Authenticated but not permitted to access resource |
INSUFFICIENT_SCOPE | 403 | API key lacks the required scope for the endpoint |
RATE_LIMITED | 429 | Request rate limit exceeded |
RATE_LIMIT_DEPENDENCY_UNAVAILABLE | 429 | Redis could not authoritatively evaluate the counter; request failed closed |
VALIDATION_ERROR | 400 | Request body or parameters failed validation |
NOT_FOUND | 404 | Requested resource does not exist |
INTERNAL_ERROR | 500 | Unexpected 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
| Error | HTTP | Cause |
|---|---|---|
missing required API key headers | 401 | One or more HMAC headers missing |
invalid timestamp | 401 | Timestamp not a valid Unix epoch |
timestamp outside acceptable window | 401 | Clock skew > 30 seconds |
invalid API key | 401 | Key not found or revoked |
API key is revoked or expired | 401 | Key was revoked by admin |
invalid passphrase | 401 | Passphrase doesn't match |
invalid signature | 401 | HMAC signature verification failed |
Orders
| Error | HTTP | Cause |
|---|---|---|
platform is paused | 503 | Trading is temporarily suspended |
market not active | 400 | Market is not in active status |
invalid side | 400 | Side must be BUY or SELL |
invalid maker_amount | 400 | Non-positive or non-numeric amount |
order already expired | 400 | Expiration timestamp is in the past |
FOK order could not be fully filled | 400 | No complete match available (FOK) |
FAK order had no immediate fills | 400 | No matching orders on the book (FAK) |
post-only order would cross the book | 400 | Order would take liquidity (post-only) |
order cannot be cancelled | 409 | Order already filled/cancelled/expired |
order not found | 404 | Order hash doesn't exist or wrong user |
Time-in-Force
| Error | HTTP | Cause |
|---|---|---|
invalid time_in_force | 400 | TIF value not GTC/GTD/FOK/FAK |
GTD orders require an expiration time | 400 | Missing expiration for GTD |
Rate Limits
| Error | HTTP | Cause |
|---|---|---|
RATE_LIMITED | 429 | Too many requests in the current window |
RATE_LIMIT_DEPENDENCY_UNAVAILABLE | 429 | Redis authority unavailable; quota unknown |
Batch Operations
| Error | HTTP | Cause |
|---|---|---|
too many orders (max 50) | 400 | Batch exceeds 50 orders |
no orders provided | 400 | Empty orders array |
no order hashes provided | 400 | Empty 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 routes | POST / DELETE /v1/orders/batch | |
|---|---|---|
| Header required? | No — omitting it places the order unguarded | No |
| Key format | Must be a UUID. Anything else is rejected with 400 IDEMPOTENCY_KEY_MALFORMED | Same |
| Same key, different payload | 409 IDEMPOTENCY_KEY_PAYLOAD_MISMATCH | Same |
| Still in flight | 409 IDEMPOTENCY_KEY_IN_PROGRESS | Same |
NOT_MATCHING_LEADER, PLATFORM_PAUSED, SETTLEMENT_UNHEALTHY | Not stored. The claim is released, so the same key re-runs | Stored 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),
});