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.