Skip to content

Errors and rate limits

How the Polaris Express API reports failures and what limits to expect on a hot loop.

All JSON endpoints return an HTTP status code that reflects the failure class, plus a JSON body. Treat the status code as authoritative; the body is for diagnostics.

{
"error": "string — short machine-readable code",
"message": "string — human-readable detail"
}

Some endpoints add an issues array for field-level validation errors:

{
"error": "validation_failed",
"message": "Request body did not match schema.",
"issues": [
{ "path": ["reason"], "message": "Expected string, received number" }
]
}
CodeMeaning
200Success. Body is the resource or operation result.
201Resource created.
204Success, no body.
400Malformed request — bad JSON, missing required field, schema mismatch.
401Not authenticated. No session cookie, expired session, or missing API key.
403Authenticated, but the caller lacks the required role or does not own the resource.
404Resource does not exist, or is scoped to a different caller.
409Conflict — duplicate resource, stale write, or state-machine violation.
422Request was well-formed but semantically invalid (e.g. referenced a Lago customer that is not active).
429Rate limit exceeded. See below.
500Unhandled server error. Safe to retry with backoff.
502 / 503 / 504Upstream dependency (SteVe, Lago, mail provider) failed or timed out. Retry with backoff.
  • 401 means “we don’t know who you are.” Re-authenticate.
  • 403 means “we know who you are, and you can’t do this.” Do not retry with the same credentials.

A 404 on a resource you believe exists usually means the resource belongs to another tenant or another customer — Polaris Express prefers 404 over 403 for cross-tenant reads to avoid leaking existence.

Endpoints that accept a JSON body validate with Zod. On failure you get 400 validation_failed with an issues array. Each issue’s path matches the JSON pointer into the request body.

Common cases:

  • Missing required field — issues[].message is Required.
  • Wrong type — Expected <type>, received <type>.
  • Unknown field — rejected on strict schemas; remove the field.

Rate limits are applied per route family and per caller identity (session user for cookie auth, API key for machine auth, IP for unauthenticated routes).

Route familyLimitWindow
Auth (/api/auth/*, magic-link send, password reset)10 requests1 minute
Customer reads (GET /api/customer/*)120 requests1 minute
Customer writes (POST/PATCH/DELETE /api/customer/*)30 requests1 minute
Admin reads300 requests1 minute
Admin writes60 requests1 minute
OCPP webhook receiverunbounded (gated by shared secret)
{
"error": "rate_limited",
"message": "Too many requests. Retry after 23s."
}

Headers on a 429:

  • Retry-After — seconds to wait before retrying.
  • X-RateLimit-Limit — the ceiling for this window.
  • X-RateLimit-Remaining0 on a 429.
  • X-RateLimit-Reset — Unix timestamp when the window resets.
  • Read Retry-After and sleep at least that long before retrying.
  • Use exponential backoff with jitter on 5xx and 429.
  • Do not retry 4xx other than 429 — the request itself is wrong.
  • For batch workloads, throttle client-side rather than relying on the server returning 429.

POST endpoints that create resources are not idempotent by default. Retrying after a network error may produce duplicates. Where an endpoint supports idempotency keys it is called out in that endpoint’s reference.

DELETE and most state-transition endpoints (e.g. report a card lost, cancel a reservation) are idempotent — repeating the call on an already-terminal resource returns 200 with the current state, not an error.

Inspect a rate-limit response:

Terminal window
curl -i https://admin.polaris.express/api/admin/users \
-H "Cookie: $SESSION"
# HTTP/1.1 429 Too Many Requests
# Retry-After: 23
# X-RateLimit-Limit: 300
# X-RateLimit-Remaining: 0
# X-RateLimit-Reset: 1737460800
# Content-Type: application/json
#
# {"error":"rate_limited","message":"Too many requests. Retry after 23s."}

Inspect a validation error:

Terminal window
curl -i https://app.polaris.express/api/customer/cards/abc123/report-lost \
-X POST \
-H "Content-Type: application/json" \
-H "Cookie: $SESSION" \
-d '{"reason": 42}'
# HTTP/1.1 400 Bad Request
# Content-Type: application/json
#
# {
# "error": "validation_failed",
# "message": "Request body did not match schema.",
# "issues": [
# { "path": ["reason"], "message": "Expected string, received number" }
# ]
# }