Errors
Every error MentionsAPI returns uses the same JSON shape and a stable error.code string. Match on the code — never on the message — to keep your integration resilient.
Error response shape
Every 4xx and 5xx response returns this shape. The request_id is the single best thing to include in a support ticket — we can look up every log line for that call.
curl -i https://api.mentionsapi.com/v1/ask \
-H "Authorization: Bearer lvk_live_deadbeef" \
-H "Content-Type: application/json" \
-d '{}'HTTP/1.1 401 Unauthorized
Content-Type: application/json
{
"error": {
"code": "unauthorized",
"message": "API key not found."
},
"request_id": "req_01H8X2Y3Z4PQR5STU6VW7XYZ8"
}Validation errors (from malformed JSON bodies) additionally include an error.details object with the flattened Zod issue list — field paths and messages you can surface to your users.
HTTP status code reference
The high-level shape of every response. Status code tells you what happened; the error.code string in the body tells you why.
| Status | Meaning | Typical cause | Retry? |
|---|---|---|---|
400 | Invalid request | Malformed body, failed Zod validation, or missing required fields. | No — fix the request |
401 | Unauthorized | Missing, malformed, or revoked API key. | No — rotate the key |
402 | Payment required | Your credit balance is insufficient for this call. | After top-up |
403 | Forbidden | Authenticated but not permitted (e.g. cross-account resource access). | No |
404 | Not found | The requested resource (ask id, monitor, webhook, key) does not exist for your account. | No |
409 | Conflict | State conflict — e.g. trying to mutate a resource in an incompatible state. | No |
410 | Gone | The archived response is older than the 30-day retention window. | No |
422 | Unprocessable entity | Body is syntactically valid but semantically wrong. | No — fix the request |
429 | Rate limited | Per-API-key token bucket exhausted. | Yes — honor Retry-After |
500 | Internal error | Unhandled exception or database failure on our side. | Yes — with backoff |
503 | Service unavailable | Temporarily unavailable — usually a downstream provider outage. | Yes — with backoff |
Error code reference
Every error.code string the API emits, grouped by top-level code (returned in error.code) and provider-level code (returned inside response.providers[].error.code on partial-success /v1/ask responses).
Top-level codes
| Code | HTTP | When | Recovery |
|---|---|---|---|
invalid_request | 400 | Request failed Zod validation, JSON body is malformed, required fields are missing (e.g. prompt OR messages), or a referenced resource id is invalid. Also fires for: async: true without webhook_id, a disabled webhook endpoint, a webhook not subscribed to ask.completed, an empty PATCH body, and start >= end on /v1/usage. | Inspect error.details (flattened Zod issues) and fix the request. Do not retry. |
unauthorized | 401 | Missing or malformed Authorization header, token does not match lvk_(live|test)_… shape, the key is unknown, revoked, or has been detached from an account. | Rotate or replace the key in the dashboard. Never retry — the token will not work on the next call either. |
insufficient_credits | 402 | Legacy /v1/ask code — credit balance too low. Newer endpoints (/v1/check, /v1/discover, /v1/compare) use insufficient_balance with the same shape. | Top up at /app/billing, then retry. |
insufficient_balance | 402 | Wallet balance is below this call's price. /v1/check, /v1/discover, /v1/compare deduct upfront — the body includes balance_cents, required_cents, and topup_url (deep-link to the dashboard). | Top up at topup_url, then retry. |
mode_roadmap | 501 | You called mode:deep or mode:change_track. Both are on the Q3 2026 roadmap and return 501 today. Body includes mode, eta, and available_modes so your code can branch deterministically. No charge. | Use mode:quick or mode:perplexity_live instead. Schedule via /v1/watch for the change-track use case. |
mode_not_yet_available | 501 | Worker is misconfigured (missing MENTIONSAPI_WORKER_SECRET or SUPABASE_URL). Should never fire in production; if it does, file a support ticket with the request_id. | Don't retry — wait for the misconfig to be resolved. |
idempotency_body_mismatch | 422 | You reused an Idempotency-Key with a different request body. Stripe-style behavior — the original key locks the body shape until it expires (24h). | Pick a fresh key, or wait 24h, or call without the header. |
forbidden | 403 | Authenticated, but attempted an operation your account is not permitted to perform. | Do not retry — contact support if the denial seems wrong. |
not_found | 404 | The addressed resource does not exist within your account — GET /v1/ask/:id with an unknown id, a monitor id that belongs to another account, a webhook_id that has been deleted, a revoked key id, or an account record that cannot be found during credit check. | Fix the id. Do not retry. |
conflict | 409 | State conflict. | Reconcile state on your side. Do not retry identically. |
expired | 410 | Returned by GET /v1/ask/:id when the archived response is past the 30-day retention window. Response body includes retention_days: 30. | Archive the payload on your side on receipt — the row is gone. |
unprocessable_entity | 422 | Request was syntactically valid but semantically rejected. | Do not retry. Inspect the message. |
rate_limited | 429 | Your API key exceeded its per-key token-bucket limit. Response includes a Retry-After header (in seconds). | Sleep for at least Retry-After seconds, then retry. Use exponential backoff on repeat 429s. |
internal_error | 500 | Unhandled exception, database read failure on archive or queue paths, or a Supabase RPC error (debit, credit, subscription sync). The response body is sanitized — the real stack is in our logs, keyed by request_id. | Retry with exponential backoff. If the error persists, include request_id in a support email. |
service_unavailable | 503 | Downstream dependency outage (provider proxy, database) or maintenance. | Retry with exponential backoff, capped at ~30s. |
request_failed | 4xx | Generic client error for any 4xx status without a more specific code mapping. | Inspect the message; do not retry. |
upstream_unavailable | 503 | An upstream live-mode surface returned a non-success status. Surfaces per-platform inside errors[platform] on multi-platform modes (so the call still 200s and you get the platforms that worked). | Retry with backoff. The upstream's failure may resolve within seconds. |
upstream_rate_limited | 503 | An upstream live-mode surface rate-limited us. Per-platform; partial-success refund applied automatically. | Retry with backoff. Don't retry the same platform faster than 30s. |
upstream_timeout | 503 | An upstream live-mode surface didn't respond within our 90-second ceiling. | Retry. Live UI capture has a heavy latency tail; intermittent timeouts are expected. |
Provider-level codes (inside /v1/ask responses)
POST /v1/ask fans out to multiple LLM providers. If one provider fails, the overall HTTP status is still 200 — the failure is reported under providers[] with one of these codes. You only pay for successful provider calls.
| Code | When | Recovery |
|---|---|---|
provider_error | Catch-all for an upstream provider failure we could not classify more specifically. | Retry idempotently; the rest of the fan-out still succeeded. |
provider_timeout | The provider request was aborted for exceeding the deadline. | Retry; consider reducing max_tokens or dropping the slow provider from providers. |
provider_rate_limited | The upstream provider returned 429 to our proxy. | Retry with backoff; transient on a per-provider basis. |
provider_auth_failed | Provider-side auth failure at the proxy (model blocked by the allowlist, or provider credentials rotated). | Verify the model override is in the provider's allowlist; email support if the issue is persistent. |
provider_unavailable | Provider returned 500/502/503 to our proxy. | Retry with exponential backoff. |
json_schema_not_supported | The call included json_schema but the targeted provider does not support structured output natively. Other providers in the same fan-out still ran. | Drop json_schema or remove the unsupported provider from providers. |
Rate-limit headers
The rate-limit headers are emitted on every authenticated response so you can track headroom without waiting for a 429. Retry-After only appears on 429 responses. The unique call identifier is returned in the JSON body as request_id, not as an HTTP header.
X-RateLimit-Limit: 30
X-RateLimit-Remaining: 27
Retry-After: 2| Header | Meaning |
|---|---|
X-RateLimit-Limit | Token-bucket capacity for your API key (requests the bucket can hold at rest). |
X-RateLimit-Remaining | Tokens remaining after this call. When this hits 0 the next request will 429 unless the bucket has refilled. |
Retry-After | Present on 429 responses. Seconds to wait before the next attempt. Always honor it — aggressive retries extend your throttle window. |
Handling errors in code
Match on error.code, honor Retry-After on 429, and back off exponentially on 500/503. Capture request_id for any error you surface in logs — it is what support will ask for.
type ApiError = {
error: { code: string; message: string; details?: unknown };
request_id?: string;
};
async function callMentionsApi<T>(
path: string,
body: unknown,
attempt = 0,
): Promise<T> {
const res = await fetch(`https://api.mentionsapi.com${path}`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.MENTIONSAPI_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (res.ok) return (await res.json()) as T;
const payload = (await res.json().catch(() => null)) as ApiError | null;
const code = payload?.error?.code ?? "unknown";
const requestId = payload?.request_id;
// 429 — always wait the header value before retrying.
if (res.status === 429 && attempt < 4) {
const retryAfter = Number(res.headers.get("Retry-After") ?? 1);
await sleep(retryAfter * 1000);
return callMentionsApi<T>(path, body, attempt + 1);
}
// 500 / 503 — exponential backoff capped at 30s.
if ((res.status === 500 || res.status === 503) && attempt < 4) {
const delay = Math.min(2 ** attempt * 500, 30_000);
await sleep(delay);
return callMentionsApi<T>(path, body, attempt + 1);
}
// 402 — balance is low. Surface to the user, don't retry.
if (code === "insufficient_credits") {
throw new Error("Top up at mentionsapi.com/app/billing");
}
// 401 / 400 / 404 etc — fix the request, don't retry.
throw new Error(
`MentionsAPI ${res.status} ${code}: ${payload?.error?.message ?? "unknown"} (request_id=${requestId})`,
);
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));import os
import time
import requests
BASE = "https://api.mentionsapi.com"
KEY = os.environ["MENTIONSAPI_KEY"]
def call_mentions_api(path: str, body: dict, attempt: int = 0):
res = requests.post(
f"{BASE}{path}",
headers={
"Authorization": f"Bearer {KEY}",
"Content-Type": "application/json",
},
json=body,
timeout=60,
)
if res.ok:
return res.json()
try:
payload = res.json()
except ValueError:
payload = {}
code = payload.get("error", {}).get("code", "unknown")
request_id = payload.get("request_id")
# 429 — honor Retry-After
if res.status_code == 429 and attempt < 4:
retry_after = int(res.headers.get("Retry-After", "1"))
time.sleep(retry_after)
return call_mentions_api(path, body, attempt + 1)
# 500 / 503 — exponential backoff capped at 30s
if res.status_code in (500, 503) and attempt < 4:
time.sleep(min(0.5 * (2 ** attempt), 30))
return call_mentions_api(path, body, attempt + 1)
if code == "insufficient_credits":
raise RuntimeError("Top up at mentionsapi.com/app/billing")
raise RuntimeError(
f"MentionsAPI {res.status_code} {code}: "
f"{payload.get('error', {}).get('message', 'unknown')} "
f"(request_id={request_id})"
)When to contact support
If a 500 persists across retries, or a 4xx response does not match what the docs describe, email [email protected] with the request_id from the response body — we can trace every log line for that call.