Errors
The error envelope, the error codes and the HTTP statuses the API answers with.
Every error from /api/v1 has the same shape, and its HTTP status matches it:
{
"error": {
"code": "not_found",
"message": "Site not found",
"details": { "…": "…" }
}
}| Field | What it is |
|---|---|
code | A stable machine-readable code from the list below. Branch on it (and on the HTTP status), not on the message. |
message | A human-readable sentence in English. It can change between versions. |
details | Present on some errors only: validation problems, a retry delay, the reason for a refusal. |
The server-side ingest endpoints use the same envelope. Two parts of an instance do not: the dashboard's sign-in and
account endpoints under /api/auth answer { "message", "code" } (the dashboard reads that shape; the
OAuth endpoints among them answer OAuth's { "error", "error_description" }), and the
MCP server answers JSON-RPC errors.
Codes
code | HTTP status | When |
|---|---|---|
bad_request | 400 | The request is malformed or breaks a rule: a parameter or body that fails validation, invalid JSON, a bad time window or filter, an invalid cursor, two credentials in one request, a report too heavy to compute. |
bad_request | 415 | A body in a content type the endpoint does not read (such as XML), a dashboard (cookie) write without a JSON or multipart body, or an upload in the wrong format. |
unauthorized | 401 | No usable credential: none was sent where one is needed, or the API key or share token is unknown, expired or revoked. Also any credential other than an API key on the key-only endpoints. |
forbidden | 403 | The caller reaches the site or organization but lacks the permission, or fails one of the checks beyond it. Also a cookie write from an untrusted Origin. |
not_found | 404 | The route does not exist, or the resource does not exist or the caller cannot see it: an unknown site and another organization's site get the same answer. |
timeout | 408 | An import upload took too long. |
conflict | 409 | The request clashes with the current state: the organization already has its maximum of API keys or of imports waiting, the site its maximum of goals, an affiliate slug is taken, another request for the same payment is in progress, a backup is already running. |
payload_too_large | 413 | The body is over the limit: 1 MiB for JSON bodies, the import file limit for uploads. |
unprocessable | 422 | A URL PilotFox was asked to fetch points somewhere it refuses to go, such as a private network address. |
rate_limited | 429 | Too many requests, or the organization already runs its maximum of exports or import uploads; see Rate limits. A request budget's 429 carries details.retryAfterSeconds and a Retry-After header that say when to retry. |
internal | 500 | An error on PilotFox's side. The message never carries internals; retry later, and report it if it persists. |
unavailable | 502 | A fetch PilotFox made for the request failed. |
unavailable | 503 | A service the request needs is not configured or not reachable (object storage for replays, imports and backups, Google Search Console on an instance without it, a payment provider that does not answer, the background jobs, the session store), or the instance is busy (too many exports, uploads or replay playbacks at once). Retry later. |
timeout | 504 | A query or an export ran past its time limit (30 seconds for a query by default; QUERY_MAX_EXECUTION_SECONDS on a self-hosted instance), or a remote fetch timed out. Narrow the window or the filters and retry. |
The /api/v1 endpoints that fetch something for you report a refused or failed fetch in their own way (install
verification and test notifications inside their 200 answer, favicons as a missing icon), so integrations rarely
see 422 or 502.
One answer comes from the HTTP server itself, before the API sees the request, and has another shape: 431 when the
request line and headers (the query string included) are larger than 16 KB. Long filter lists are the usual cause.
{ "error": "Request Header Fields Too Large", "message": "Exceeded maximum allowed HTTP header size", "statusCode": 431 }Validation errors
A query string, path parameter or body that fails its schema is a 400 with message "Invalid request" and one entry
per problem in details. Validation comes before the permission check, so a request that fails it gets this 400
even without a credential or the permission (an unknown, expired or deleted key is still a 401):
curl -G -H "Authorization: Bearer $PILOTFOX_API_KEY" \
"https://pilotfox.app/api/v1/sites/SITE_ID/overview" \
--data-urlencode 'last=7d' \
--data-urlencode 'filters=[{"dim":"country","op":"gt","values":["US"]}]'{
"error": {
"code": "bad_request",
"message": "Invalid request",
"details": [
{
"in": "querystring",
"path": "/filters/0/values",
"code": "custom",
"message": "gt takes exactly one number"
}
]
}
}details[] field | What it is |
|---|---|
in | Where the problem is: querystring, body, params or headers. |
path | A JSON pointer to the value inside it (/ for a rule about the whole query, such as "exactly one of start+end, last or all is required"). |
code | The kind of check that failed, such as invalid_type, invalid_value, too_big or custom. |
message | What is wrong. |
Rules checked after validation answer 400 with their own message and no list, for example
"Unknown dimension: colour", "Invalid cursor" or
"Filtering by querystring requires the sessions:read permission". Some carry details, such as
{ "dim": "user_id" } for an unknown breakdown dimension.
Other details
| Error | details |
|---|---|
Two credentials in one request (400) | { "reason": "ambiguous_credentials" } |
Rate limited (429) | { "retryAfterSeconds": 60 }, with the same value in the Retry-After header |
An error raised by the authentication library inside an /api/v1 endpoint (4xx) | { "code": "…" }, that library's own error code |
Handling errors
400,403,404,409,413,415,422: fix the request; retrying it unchanged usually gives the same answer (a409for a payment already being written clears once that request finishes).401: check the key (expired? deleted?) and that you send exactly one credential.429: waitRetry-Afterseconds, then retry.500,502,503,504: retry with backoff. For504, a narrower window or fewer filters usually helps.
async function pilotfox(path, attempt = 1) {
const res = await fetch(`https://pilotfox.app/api/v1${path}`, {
headers: { Authorization: `Bearer ${process.env.PILOTFOX_API_KEY}` },
});
if (res.status === 429 && attempt < 5) {
const wait = Number(res.headers.get("retry-after") ?? 60);
await new Promise((resolve) => setTimeout(resolve, wait * 1000));
return pilotfox(path, attempt + 1);
}
const body = await res.json();
if (!res.ok) throw Object.assign(new Error(body.error.message), { status: res.status, ...body.error });
return body;
}
const { data } = await pilotfox("/sites/SITE_ID/overview?last=7d");