Error Handling
Every non-2xx response from the Not AI Public REST API uses the same JSON envelope and the same enumerated code list. Treat the HTTP status as the coarse signal and the error.code string as the stable identifier you write logic against.
Error envelope
{
"error": {
"code": "INVALID_REQUEST",
"message": "pageSize must be between 1 and 100.",
"details": {
"field": "pageSize"
}
}
}
codeis a stable identifier from the enumerated list below. Codes never change meaning inside a major version.messageis a human-readable sentence. Treat it as opaque. Copy changes are not breaking.detailsis always present in the envelope and isnullunless the error carries field-level context. Validation errors populatedetails.fieldwith the offending parameter name; every other error sends"details": null. Model it as a nullable object; a deserializer that requires a non-null object breaks on most real error responses.
Health (GET /health) and the OpenAPI document (GET /openapi/v3.json) are the only routes that never return this envelope. They have no error responses defined in v1.
HTTP status codes
| Status | Meaning |
|---|---|
200 |
Success. Body is the response envelope. |
204 |
Success with no body. Used where the operation is intentionally empty. |
400 |
The request was malformed or failed validation. details will name the offending field. |
401 |
The request was unauthenticated. The key was missing (MISSING_API_KEY) or invalid (INVALID_API_KEY — malformed, unknown, or for the wrong region). |
402 |
A plan-tier limit was reached (e.g. registering a webhook subscription past the plan’s cap). Upgrade or free a slot. |
403 |
The key is valid but the operation is not permitted. Note: a resource that belongs to another integration returns 404, not 403 (see below). |
404 |
The requested resource does not exist, or belongs to another integration. The API deliberately collapses “does not exist” and “not yours” into the same 404 NOT_FOUND so resource ids cannot be enumerated across tenants. |
429 |
A per-minute rate-limit window was exhausted. A Retry-After header is included when the edge can compute one; if absent, back off with exponential delay. See Rate Limits. |
5xx |
The API failed to serve the request. Retryable for idempotent reads. |
Stable error codes
These codes are stamped on the v1 contract. New codes may be added in v1 (additive change); existing codes will never be removed or repurposed.
| Code | Typical status | When you see it |
|---|---|---|
MISSING_API_KEY |
401 | The request carried no Authorization: Bearer and no x-api-key header. |
INVALID_API_KEY |
401 | The presented key was malformed, unknown, revoked, or for the wrong region. Identical envelope for all four cases by design. |
INVALID_REQUEST |
400 | A query string or path parameter failed validation. details names the offending field. |
INVALID_BODY |
400 | The request body was malformed, missing a required field, or otherwise not valid JSON for the target route. |
INVALID_URL |
400 | A webhook subscription URL was rejected by the SSRF / format validator (bad scheme, non-80/443 port, userinfo, private/loopback/link-local host, blocked metadata host, URL longer than 2048 chars). |
INVALID_EVENT_TYPE |
400 | A webhook subscription’s eventType was not one of the seven documented event-type strings (BotDetected, SessionAnomaly, ThresholdExceeded, AlertTriggered, ReportReady, SessionCorrelated, WritingSessionScored; case-insensitive on input). |
NOT_FOUND |
404 | The path resolved to a resource that does not exist or is not visible to this integration. Cross-tenant access intentionally returns this code, not FORBIDDEN, to avoid leaking which ids exist. |
FORBIDDEN |
403 | The key resolved successfully but the operation is not permitted. Reserved on the current surface; cross-tenant resource access surfaces as NOT_FOUND instead. |
PLAN_LIMIT_REACHED |
402 | The integration is at the plan-tier cap for the resource (e.g. webhook subscriptions). Free a slot or upgrade. |
RATE_LIMITED |
429 | A per-minute rate-limit window was exhausted. Authorization: Bearer callers are isolated by key; x-api-key callers share a per-tier edge bucket and can be throttled by unrelated same-tier traffic. Honor Retry-After when present; otherwise back off exponentially. |
UNSUPPORTED_OPERATION |
400 | The endpoint is documented but the specific combination of arguments is not yet supported. Adjust the request. |
SERVICE_UNAVAILABLE |
503 | A downstream dependency is unavailable. Retry with backoff. |
INTERNAL_ERROR |
500 | An unhandled exception was caught by the global error middleware. Retry with backoff and report if it persists. |
Examples
400, bad query string:
{
"error": {
"code": "INVALID_REQUEST",
"message": "pageSize must be between 1 and 100.",
"details": { "field": "pageSize" }
}
}
401, the credential was malformed or unknown (note the explicit "details": null, which is what every non-validation error sends):
{
"error": {
"code": "INVALID_API_KEY",
"message": "The supplied API key is not valid.",
"details": null
}
}
401, no credential was presented:
{
"error": {
"code": "MISSING_API_KEY",
"message": "Provide your API key via 'Authorization: Bearer aik_v1_...' or the 'x-api-key' header.",
"details": null
}
}
404, unknown session id:
{
"error": {
"code": "NOT_FOUND",
"message": "No session with that id is visible to this integration.",
"details": null
}
}
500, unhandled server-side fault:
{
"error": {
"code": "INTERNAL_ERROR",
"message": "An unexpected error occurred.",
"details": null
}
}
Retry semantics
The API performs no automatic server-side retries on your behalf. Clients should implement their own retry policy with the following rules:
- 2xx. Never retry.
- 400, 401, 403, 404. Never retry. Fix the request and try again.
- 429. Honor
Retry-Afterwhen the response includes it (it is an integer number of seconds, never an HTTP-date); otherwise back off with exponential delay and jitter. Do not retry sooner than the floor of either path. A 429 can occur on every authenticated route, even though per-endpoint response lists do not repeat it. - 5xx (500, 502, 503, 504). Retry the request with exponential backoff and jitter.
GETretries are always safe. For mutating requests, use the per-route semantics below.
Per-route idempotency of the write endpoints (what a retry after a timeout or 5xx actually does):
| Route | Repeat-call semantics | Safe to blind-retry? |
|---|---|---|
POST /v1/sessions/{id}/label |
Idempotent upsert: the new label overwrites the old one. | Yes |
POST /v1/users/{id}/devices/{fp}/confirm |
Idempotent upsert: refreshes confirmedAt on the same pair. Unknown pairs are registered, not 404ed. |
Yes |
PUT /v1/settings/risk-thresholds |
Full replace; same body converges to the same state. | Yes |
POST /v1/webhooks |
Not idempotent: every call creates a new subscription with a fresh secret. | No; reconcile via GET /v1/webhooks first |
PATCH /v1/webhooks/{id} |
Applies only the fields present; same body converges. | Yes |
DELETE /v1/webhooks/{id} |
Second call returns 404, which means the delete already succeeded. |
Yes; treat 404 as success |
POST /v1/webhooks/{id}/rotate-secret |
Not idempotent: every call rotates again and invalidates the previous secret. | No; rotate deliberately, store, verify with /test |
POST /v1/webhooks/{id}/test |
Fires one synthetic delivery per call. | Yes (each retry fires another test delivery) |
A reasonable default policy for a server-to-server consumer:
import os
import time
import random
import requests
BASE_URL = "https://api.isnotai.com"
HEADERS = {"Authorization": f"Bearer {os.environ['ISNOTAI_API_KEY']}"}
def get_with_retry(path, params=None, max_attempts=5):
for attempt in range(max_attempts):
response = requests.get(f"{BASE_URL}{path}", headers=HEADERS, params=params, timeout=10)
if response.status_code < 400:
return response.json()
if response.status_code == 429:
retry_after = response.headers.get("Retry-After")
if retry_after and retry_after.isdigit():
time.sleep(int(retry_after))
else:
time.sleep((2 ** attempt) + random.random())
continue
if 500 <= response.status_code < 600:
backoff = (2 ** attempt) + random.random()
time.sleep(backoff)
continue
response.raise_for_status()
raise RuntimeError(f"giving up on {path} after {max_attempts} attempts")
Log the full error envelope (code, message, and details) when a retry policy gives up. That is what Not AI support will need to triage.