PilotFoxDocs

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": { "…": "…" }
  }
}
FieldWhat it is
codeA stable machine-readable code from the list below. Branch on it (and on the HTTP status), not on the message.
messageA human-readable sentence in English. It can change between versions.
detailsPresent 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

codeHTTP statusWhen
bad_request400The 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_request415A 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.
unauthorized401No 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.
forbidden403The 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_found404The 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.
timeout408An import upload took too long.
conflict409The 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_large413The body is over the limit: 1 MiB for JSON bodies, the import file limit for uploads.
unprocessable422A URL PilotFox was asked to fetch points somewhere it refuses to go, such as a private network address.
rate_limited429Too 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.
internal500An error on PilotFox's side. The message never carries internals; retry later, and report it if it persists.
unavailable502A fetch PilotFox made for the request failed.
unavailable503A 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.
timeout504A 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[] fieldWhat it is
inWhere the problem is: querystring, body, params or headers.
pathA 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").
codeThe kind of check that failed, such as invalid_type, invalid_value, too_big or custom.
messageWhat 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

Errordetails
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 (a 409 for a payment already being written clears once that request finishes).
  • 401: check the key (expired? deleted?) and that you send exactly one credential.
  • 429: wait Retry-After seconds, then retry.
  • 500, 502, 503, 504: retry with backoff. For 504, a narrower window or fewer filters usually helps.
Node.js
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");

On this page