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 - Rate limit exceeded |
| 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 |
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"
}
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 limit exceeded | 429 | Too many requests per window |
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:
# Rate limited - wait and retry
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 resetTime = parseInt(resp.headers.get('X-RateLimit-Reset') ?? '0');
const waitMs = Math.max(1000, (resetTime - 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 and POST /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. Keys are retained for 24 hours.
POST /v1/orders | POST /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 | Not validated |
| Same key, different payload | 409 IDEMPOTENCY_KEY_PAYLOAD_MISMATCH | 409 IDEMPOTENCY_CONFLICT |
| Still in flight | 409 IDEMPOTENCY_IN_PROGRESS | 409 IDEMPOTENCY_IN_PROGRESS |
The UUID requirement is enforced on /v1/orders only. Send a UUID on both and
you will satisfy either endpoint.
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.
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),
});