Skip to content

Handling errors

When something goes wrong, fetch.li returns JSON with an error object. Your app, spreadsheet, or Zap should check the HTTP status code and read error.code before treating the response as success.

Error shape

Every error response follows this structure:

{
  "error": {
    "code": "invalid_body",
    "message": "Human-readable explanation.",
    "reason": "OPTIONAL_MACHINE_REASON",
    "details": {}
  }
}
Field Always present? Meaning
error.code Yes Stable identifier for your code to branch on
error.message Yes Plain English explanation
error.reason Sometimes Extra detail from Unkey or validation
error.details Sometimes Structured validation errors (often on invalid_body)

Successful responses do not wrap data in { "data": ... }. Only errors use the error key.

HTTP status codes

Status Typical error.code Meaning
400 invalid_body Bad JSON body (POST) or invalid/missing query parameters (GET)
401 unauthorized Missing or invalid API key (protected routes)
403 forbidden Key valid but not permitted for this route
404 not_found Unknown path or resource (e.g. invalid postcode)
429 rate_limited Demo hourly limit or key usage exceeded
502 upstream_error Upstream provider failed (Anthropic, or the SMTP lane refused a send)
503 unkey_unavailable or unavailable Key verification down, or no @fetch.li mailer configured
500 internal Unexpected server error

Examples by code

invalid_body (400)

POST classify with an empty body:

{
  "error": {
    "code": "invalid_body",
    "message": "Request body must be JSON."
  }
}

POST classify with too many strings:

{
  "error": {
    "code": "invalid_body",
    "message": "Send 1-20 non-empty strings as a JSON array or { texts: string[] }.",
    "details": {
      "formErrors": [],
      "fieldErrors": {}
    }
  }
}

GET with a missing required parameter:

{
  "error": {
    "code": "invalid_body",
    "message": "Query parameter 'code' is required."
  }
}

unauthorized (401)

{
  "error": {
    "code": "unauthorized",
    "message": "Send Authorization: Bearer <token> with a valid fetch.li API key."
  }
}

Or key rejected:

{
  "error": {
    "code": "unauthorized",
    "message": "API key was rejected.",
    "reason": "INVALID"
  }
}

rate_limited (429)

Demo tier:

{
  "error": {
    "code": "rate_limited",
    "message": "Demo rate limit exceeded. Try again later or use an API key for higher limits.",
    "reason": "DEMO_HOURLY_LIMIT"
  }
}

Keyed usage:

{
  "error": {
    "code": "rate_limited",
    "message": "API key was rejected.",
    "reason": "USAGE_EXCEEDED"
  }
}

not_found (404)

Unknown route:

{
  "error": {
    "code": "not_found",
    "message": "No route for GET /v1/unknown"
  }
}

Invalid postcode:

{
  "error": {
    "code": "not_found",
    "message": "Postcode not found."
  }
}

upstream_error (502)

Classify when Anthropic is unavailable:

{
  "error": {
    "code": "upstream_error",
    "message": "Classification provider returned an error."
  }
}

Request IDs

Every response includes the header:

X-Request-Id: 0b3e1c2a-7d44-4f1a-9c8a-2a6f4d1e9b10

Log this value with errors. Support can trace the exact request on the server.

Retry guidance

Code Retry? How
invalid_body No Fix parameters or body
unauthorized / forbidden No Fix key or permissions
not_found No Fix URL or input data
rate_limited Yes, later Exponential backoff; reduce call frequency
unkey_unavailable Yes Short backoff, few retries
upstream_error Yes Limited retries with backoff
internal Maybe once Retry once; then alert

JavaScript example

async function fetchWeather(postcode) {
  const url = `https://fetch.li/v1/weather?postcode=${encodeURIComponent(postcode)}`;
  const response = await fetch(url);
  const body = await response.json();

  if (!response.ok) {
    const code = body.error?.code ?? "unknown";
    const message = body.error?.message ?? response.statusText;
    const requestId = response.headers.get("X-Request-Id");
    throw new Error(`${code}: ${message} (request ${requestId})`);
  }

  return body;
}

Python example

import requests

def fetch_weather(postcode: str) -> dict:
    url = "https://fetch.li/v1/weather"
    response = requests.get(url, params={"postcode": postcode})

    if not response.ok:
        payload = response.json()
        err = payload.get("error", {})
        raise RuntimeError(
            f"{err.get('code')}: {err.get('message')} "
            f"(request {response.headers.get('X-Request-Id')})"
        )

    return response.json()

Demo note

GET endpoints return these same error shapes on the demo tier. POST /v1/vibe/classify adds auth-related codes and requires a key. POST /v1/mail/contact is a desk route: 503 unavailable means no Proton or SMTP lane is configured.

Related pages