API errors /v1¶
Every error response from /v1/* has the same shape (api.contracts.ErrorResponse, #46, ADR 0015):
{"error": {"code": "unknown_rule", "message": "Unknown rules: NOPE",
"details": {"rules": ["NOPE"]}, "request_id": "6cb511c42fb241ec9e04334766829427"}}
code— stable machine identifier (below). Clients branch oncode, never onmessage.message— human-readable description, max 300 characters; may change without a version bump.details— optional structured data (e.g. a list of validation errors),nullif none.request_id— same as theX-Request-IDheader; quote it when reporting a problem.
Every /v1 response (including success and 500) has X-Request-ID and X-API-Version: v1 headers. A client may
send its own X-Request-ID ([A-Za-z0-9._-]{1,128}) — it is used instead of a generated one.
Error codes¶
| HTTP | code |
When |
|---|---|---|
| 400 | unknown_rule |
rules contains ids outside the catalog; details.rules = unknown ids |
| 400 | unknown_profile |
unknown profile (superadmin override only; other callers' profile is ignored, #314) |
| 400 | rule_disabled |
rules contains ids disabled in the catalog; details.rules = disabled ids |
| 400 | invalid_webhook_url |
webhook_url not https, an IP literal, localhost/internal host, or has credentials (#51) |
| 400 | bad_request |
other request error |
| 400 | invalid_url |
page url rejected before analysis (#111): IP literal, localhost, .local/.internal, credentials, scheme other than http(s), port other than 80/443; details = {url} |
| 401 | unauthorized |
not logged in (JWT Authorization: Bearer, API key Bearer gsj_... or session cookie, #48/#50) |
| 400 | invalid_key_expiry |
expires_at in the past or more than 1 year ahead, or never_expires on a non-organization key / by a non-owner/admin (#125) |
| 401 | invalid_api_key |
gsj_... key unknown, wrong secret or checksum, revoked (also after the rotation grace period or a leak report), its project deleted, or a user key whose owner left the organization (#50, #125) |
| 401 | api_key_expired |
the key passed its expires_at; create or rotate a key (#125) |
| 403 | csrf_failed |
session-cookie request without a matching Origin/Referer |
| 403 | forbidden |
role too low (require_role), no permission in the project, or user-only operation (e.g. managing keys) |
| 403 | ip_not_allowed |
the key has an IP allowlist and the client address is outside it (#125) |
| 403 | insufficient_scope |
API key lacks the required scope (judge, reports:read, jobs, projects:read, #50) |
| 403 | org_suspended |
the current organization (from X-Org-ID, the only org of the account, or the API key's project) is suspended (#124) |
| 404 | report_not_found |
GET /v1/reports/{id} — no such report; also any id that is not a public id (rep_…), e.g. an old integer id (#142) |
| 404 | job_not_found |
GET /v1/jobs/{id} — no such job in the caller's projects (#51) |
| 404 | audit_not_found |
GET /v1/audits/{id} — no such audit in the caller's projects (#87) |
| 404 | intent_not_found |
GET /v1/intent/{id} and exports - no such intent report or batch in the caller's projects (#194) |
| 422 | tier_unavailable |
POST /v1/intent* - the requested Intent tier is not available yet (#194) |
| 422 | invalid_csv |
POST /v1/intent/batch/csv - the CSV has no phrases or a row has a bad market (#194) |
| 403 | private_project |
POST /v1/intent/{id}/shares - intent reports in a private project cannot be shared publicly (#194) |
| 503 | pdf_unavailable |
GET /v1/intent/{id}/export.pdf - PDF rendering is unavailable on this server (#194) |
| 404 | share_not_found |
DELETE /v1/share-links/{id} (and per report/audit) — no active share link with this id in the caller's organization (#126) |
| 404 | org_not_found |
X-Org-ID names an unknown organization, one without the caller's membership, or a different org than the API key's project (#124) |
| 409 | upgrade_unavailable |
POST /v1/reports/{id}/upgrade, /v1/audits/{id}/upgrade (#192): nothing to add; details.reason = nothing_to_add | busy | private_mode | no_pages, details.estimate |
| 413 | audit_too_large |
POST /v1/audits/site — pages over GSPAMJUDGE_AUDITS_MAX_PAGES (#87), or over GSPAMJUDGE_BETA_AUDIT_MAX_PAGES (200) for beta-plan orgs (#220; API and panel) |
| 422 | sitemap_not_found |
POST /v1/audits/site — no sitemap URLs for the site (#87) |
| 422 | invalid_scope |
/v1/audits/site* — invalid include/exclude rule (globs only) (#119) |
| 422 | empty_selection |
POST /v1/audits/site — no URLs left after include/exclude (#119) |
| 422 | preview_expired |
/v1/audits/site* — preview_id expired, unknown or from another user/project, and no source given (#119) |
| 404 | not_found |
path does not exist |
| 405 | method_not_allowed |
wrong HTTP method |
| 409 | key_not_active |
POST /v1/keys/{id}/rotate on a revoked, expired or already rotated key (#125) |
| 409 | conflict |
resource already exists, e.g. POST /v1/invites for an e-mail with an account or a pending invite (#89) |
| 413 | batch_too_large |
POST /v1/judge/batch over GSPAMJUDGE_BATCH_MAX pages — use /v1/jobs |
| 413 | job_too_large |
POST /v1/jobs over GSPAMJUDGE_JOBS_MAX items (#51) |
| 413 | item_too_large |
POST /v1/jobs, /v1/audits/site* (import items, #119): html/text over GSPAMJUDGE_JOBS_ITEM_MAX_CHARS per item or GSPAMJUDGE_JOBS_MAX_CHARS per job |
| 413 | payload_too_large |
other oversized request |
| 422 | validation_error |
body/parameters do not match the schema; details[] = {loc, msg, type} |
| 400 | registration_failed |
Registration refused (#387); no longer returned by POST /v1/auth/register since #400 |
POST /v1/auth/register (#400) always answers 202 {status: "pending", message} with the same body, whether or not the e-mail has an account, so the status does not reveal existing accounts. The account becomes active only after the link from the e-mail (POST /v1/auth/confirm {token} or the panel page /confirm/{token}); an existing account gets an informational e-mail instead. Only a password that breaks the rules returns 400 invalid_password (checked the same way for every e-mail). An unconfirmed account cannot sign in (invalid_credentials).
| HTTP | code |
When |
|---|---|---|
| 400 | email_unavailable |
PATCH /v1/users/me: the new e-mail cannot be used (#387) |
| 400 | invalid_password |
Password does not meet the rules: min. 8 characters, no name from the e-mail (#387) |
| 400 | invalid_credentials |
Sign-in failed: wrong e-mail or password (#387) |
| 400 | account_not_verified |
Sign-in requires a confirmed e-mail (#387) |
| 400 | invalid_token |
Password reset or e-mail confirmation link invalid or expired (#387, POST /v1/auth/confirm #400) |
| 429 | rate_limited |
API key rate limit exceeded (#50); headers Retry-After, RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset |
| 429 | rate_limited |
Too many sign-in, registration, e-mail confirmation or password reset attempts (#68, #400): per IP and per e-mail, 10/min, growing block; header Retry-After |
| 429 | too_many_active_jobs |
project already has GSPAMJUDGE_JOBS_MAX_ACTIVE queued/running jobs, or the organization has GSPAMJUDGE_JOBS_MAX_ACTIVE_ORG (2) queued/running jobs and site audits (#220); Retry-After |
| 503 | spend_cap_reached |
global daily spend cap reached (#94, GSPAMJUDGE_DAILY_SPEND_CAP_USD, UTC day); details = {day, spent_usd, cap_usd}, Retry-After: 3600; resets at 00:00 UTC |
| 503 | beta_budget_reached |
closed beta paused: global monthly beta budget used up (#220, GSPAMJUDGE_BETA_MONTHLY_BUDGET_USD, UTC month; 0 = off); message "Beta paused: the monthly beta limit has been reached…" (PL with Accept-Language: pl); details = {month, spent_usd, budget_usd}, Retry-After: 3600; checked before every judge, batch, job, site audit, upgrade and JS retry |
| 429 | quota_exceeded |
spending limit reached (#89, #117, #221): per user, project or organization (monthly allowance, then PAYG balance), per day or month (UTC); amounts in USD, message e.g. "Monthly limit of $20.00 reached for this organization: used $19.99, balance $0.00, requested $0.0450."; details = {subject, period, unit: "usd", limit_usd, used_usd, requested_usd, remaining_usd} (+ balance_usd for org); a batch must fit entirely. Usage: GET /v1/usage, see Billing |
| 500 | internal_error |
unexpected server error; generic message (see below) |
| 502 | fetch_failed |
the page at url could not be fetched (HTTP error, timeout, DNS; host resolving to a non-public address, redirect to one, body over 10 MB) |
| 502 | inference_failed |
no rule could be scored (the inference provider failed for every rule); the page is not charged |
| 503 | unavailable |
service temporarily unavailable |
| 503 | rate_limiter_unavailable |
Redis rate limiter unreachable (fail-closed; GSPAMJUDGE_APIKEY_RATE_FAIL_OPEN=true lets requests through) |
| 503 | queue_unavailable |
POST /v1/jobs: job queue (Redis) not configured or unreachable (#51) |
| 503 | webhooks_unavailable |
POST /v1/jobs with webhook_url, but the server has no webhook signing secret (#51) |
message for 500/502 is generic by default; the exception text (type + message, max 300 characters) appears only
with GSPAMJUDGE_EXPOSE_ERRORS=true (dev). Details are always in the server log under request_id.
A new code is a non-breaking change (changelog entry); changing the meaning of or removing a code happens only in a
new version. Errors of single pages in a batch are not HTTP errors: they go to results[].error (response 200).
Paths outside /v1 (panel, /health) keep FastAPI's default responses.