Skip to main content
Every error response from the API uses the same JSON shape. Whether it came from Next.js (auth, rate limiting) or the Go backend (validation, ownership, data fetching), the envelope is identical.
{
  "error": {
    "type": "<machine-readable category>",
    "message": "<human-readable explanation>"
  }
}

Status codes you’ll actually see

HTTPerror.typeWhen
400invalid_request_errorMalformed input — bad UUID, bad date, unknown metric name, wrong HTTP method.
401authentication_errorMissing / malformed / unknown / revoked Bearer key.
403permission_errorKey is valid but the caller doesn’t own the requested universe.
404not_foundUniverse doesn’t exist, or no history row for the given filters.
429rate_limit_errorPer-key request budget exceeded. See Rate limits.
500api_errorServer bug or upstream DB failure. Try again with backoff; if it persists, reach out.
503api_errorBackend temporarily unreachable. Retry with backoff.

Common cases

Forgot the header

HTTP/1.1 401 Unauthorized
{
  "error": {
    "type": "authentication_error",
    "message": "Missing or malformed Authorization header. Expected: 'Authorization: Bearer <api_key>'."
  }
}

Bad UUID in the path

HTTP/1.1 400 Bad Request
{
  "error": {
    "type": "invalid_request_error",
    "message": "universe_id is not a valid UUID"
  }
}

Wrong owner

HTTP/1.1 403 Forbidden
{
  "error": {
    "type": "permission_error",
    "message": "access denied: this universe does not belong to your account"
  }
}

No data for that filter combo

HTTP/1.1 404 Not Found
{
  "error": {
    "type": "not_found",
    "message": "no data found for this metric/universe/date combination"
  }
}

Bad date

HTTP/1.1 400 Bad Request
{
  "error": {
    "type": "invalid_request_error",
    "message": "invalid filter value (check `day` format YYYY-MM-DD)"
  }
}

Idiomatic client handling

import time, requests

def get(path, key):
    r = requests.get(f"https://verseodin.com/api/v1{path}",
                     headers={"Authorization": f"Bearer {key}"})

    if 200 <= r.status_code < 300:
        return r.json()

    body = r.json().get("error", {})
    err_type, msg = body.get("type"), body.get("message")

    if err_type == "rate_limit_error":
        time.sleep(int(r.headers.get("Retry-After", "5")) + 1)
        return get(path, key)
    if err_type == "authentication_error":
        raise PermissionError(f"check your VERSEODIN_API_KEY: {msg}")
    if err_type in ("permission_error", "not_found"):
        return None  # caller can decide
    if err_type == "invalid_request_error":
        raise ValueError(msg)
    raise RuntimeError(f"verseodin api {r.status_code} {err_type}: {msg}")

What we don’t do (and why)

  • No RFC 7807 application/problem+json — the format hasn’t reached meaningful adoption among public APIs developers integrate with daily. We follow Stripe’s pattern instead because that’s what the ecosystem is already wired to consume.
  • No nested error trees / multiple errors per response — every failed response carries exactly one error object. If you submitted multiple invalid filters, you’ll see the first one we noticed.
  • No localised messagesmessage strings are English-only. Don’t show them to end users verbatim; show your own copy and log ours.