Skip to content

Errors and rate limits

Errors are RFC 9457 problem documents (application/problem+json) with a stable machine code. Match on code, not on title or detail.

{
"type": "https://opdns.io/problems/validation_failed",
"title": "Validation failed",
"status": 422,
"code": "validation_failed",
"errors": [{ "field": "settings.retention_days", "message": "must be 1 to 90" }],
"request_id": "…"
}

Include request_id when you report a problem. The full list of codes is the Problem schema in the reference.

Status Common codes
400 bad_request, body_too_large, bad_if_match, invalid_token, token_expired, token_used, invalid_cursor, bad_idempotency_key
401 unauthenticated, second_factor_required, invalid_recovery_code, node_revoked
403 insufficient_scope, session_required, csrf_rejected, reauth_required, mfa_enrolment_required, mfa_required
404 not_found (also for another organisation’s resources)
409 ip_conflict, idempotency_key_reused, idempotency_in_progress, profile_not_empty, profile_suspended, node_token_superseded
412 version_mismatch
422 validation_failed, unknown_list, too_many_rules, password_too_short, password_too_long, range_too_large, query_too_expensive
429 rate_limited, too_many_streams, too_many_reports, too_many_queries
501 not_implemented, shape_unsupported
502, 503, 504 node_error; node_offline, node_busy, relay_unavailable; node_timeout, query_timeout

A new password (sign-up, change, reset, recovery) of fewer than 12 or more than 128 Unicode code points is 422 password_too_short or password_too_long, with the password field in errors; when other fields are invalid too, the answer is validation_failed listing them all. There is no other password rule.

409 profile_suspended answers a change to a profile an operator has suspended: its settings, rules, lists, linked IPs, devices, deletion and new nodes. Reads still work. The profile’s suspended field says so in advance; see Suspended profiles.

Every profile resource returns its version as a strong ETag ("3"). Send it back as If-Match on a write; if someone changed the profile in between, the write fails with 412 version_mismatch and nothing changes. Omit If-Match to write unconditionally. Every successful write bumps the version and publishes the whole profile to the resolvers.

Terminal window
curl -X PATCH https://api.opdns.io/v1/profiles/abc123 \
-H "Authorization: Bearer $OPDNS_TOKEN" \
-H 'If-Match: "3"' -H 'Content-Type: application/json' \
-d '{"settings":{"retention_days":7}}'

Every list endpoint is cursor-paginated. Send limit (default 50, at most 200) and, for the next page, cursor set to the previous page’s next_cursor; next_cursor is null on the last page. The order is stable, so rows created while you page are never repeated or skipped before the cursor. Cursors are opaque: a modified one is 400 invalid_cursor.

Terminal window
curl -H "Authorization: Bearer $OPDNS_TOKEN" \
'https://api.opdns.io/v1/profiles?limit=100&cursor=…'

Log queries page the same way, with their own limit (at most 1,000 rows).

POSTs that create something (profiles, tokens, linked IPs, nodes, exports, operator and source blocks, list reports) accept an Idempotency-Key header, any unique string such as a UUID. Repeating the request with the same key and the same body within 24 hours replays the first successful answer, with Idempotent-Replayed: true, instead of creating a second one. So a script can retry a request whose answer it never received.

Situation Answer
same key, same request, first one succeeded the first answer again, Idempotent-Replayed: true
same key, first one still running 409 idempotency_in_progress with Retry-After; retry with the same key
same key, different request 409 idempotency_key_reused
first one failed nothing is stored; the key can be used again

Keys are per account.

GET /v1/profiles/{id}/logs and /analytics/{shape} answer a QueryResult: columns, rows, source (clickhouse, node or none), next_cursor, and partial.

Log rows (tail and search) carry the stored columns (ts, source, pop, node, profile_id, device_id, device_name, client_ip, transport, qname, qtype, status, reason_list_id, reason_rule, rcode, dnssec_validated, cache_hit, latency_us, answer_ips) and four derived ones, computed the same way for cloud and node results, whatever the node’s version:

Column Holds
reason the list name when a list decided, else the rule or operator text as stored (denylist: *.example.com, rewrite: =nas.home, legal_order LO-17); empty when domain logging blanked a rule
reason_code list, rule, operator:<code> (legal_order, abuse, csam), rewrite, or none for allowed and failed queries
ede_code the Extended DNS Error the client received: 15 for operator blocks, 17 for every other block, 0 otherwise (a failed upstream’s EDE is not recorded)
ede_text the EDE extra text: the reason of a block

The top_reasons analytics shape groups queries by what decided them: reason_code, list_id (for list only), reason and count, most frequent first. Without a status filter it counts blocked and rewritten queries. A self-hosted node older than this shape answers 501 shape_unsupported.

The destinations shape groups allowed queries by answer address: ip, count, and since API 0.2.0 two more columns:

Column Holds
country the ISO 3166-1 alpha-2 code of the address from the server’s GeoIP database; "" when unknown, when the address is not public, or when the deployment has no database
owner the company whose address it is: google, apple, meta, amazon, microsoft, or the CDNs cloudflare, akamai, fastly; "" for any other

The API adds both columns to answers that lack them (a node older than this change) and fills an empty country from its own database, so a relayed answer has countries even though nodes have none. When the database’s licence asks for credit, geoip_attribution in GET /v1/meta holds the notice to show next to the countries. How both are derived: GeoIP and owner classification.

The top_owners shape counts queries by the company they reached: owner and count, most first, ties by owner. A query’s owner is its name’s (a curated list of domain suffixes: youtube.com is Google’s), or else its first classified answer address’s; queries with neither are left out. It has no default status filter. A node older than this shape answers 501 shape_unsupported.

The top_pops shape (API 0.5.0) counts queries by the PoP that answered them: pop and count, most first, ties by pop. pop is the PoP’s short id (ams), or node for queries a self-hosted node answered itself; records without a PoP are left out. The default limit is 10, at most 100, and there is no default status filter. A node older than this shape answers 501 shape_unsupported.

Terminal window
curl -H "Authorization: Bearer $OPDNS_TOKEN" \
'https://api.opdns.io/v1/profiles/abc123/analytics/top_pops?limit=20'

To name and place the ids, GET /v1/pops (no authentication) lists the deployment’s PoP catalogue, sorted by id: id, name (the label to show, the city), city, country (ISO 3166-1 alpha-2), lat and lon (WGS 84 degrees) and status (active, canary: serving and receiving changes first, or planned: not serving yet). It changes only when the fleet does, so it may be cached for five minutes (Cache-Control: public, max-age=300); a deployment without PoP locations answers an empty array.

[
{ "id": "ams", "name": "Amsterdam", "city": "Amsterdam", "country": "NL",
"lat": 52.37, "lon": 4.9, "status": "active" }
]

partial is true when a node answered with fewer rows than asked (partial_reason timeout, near its query deadline, or overload), or when a self-hosted profile’s node is between link sessions (node_reconnecting, no rows; a tail keeps its cursor). The rows present are correct.

Every log and analytics query passes the same guardrails before any store is read, whether the cloud’s log database or your self-hosted node answers it.

Range. Each shape covers at most a fixed time range:

Shapes Longest from..to
search, top_devices, destinations, top_pops (read every row of the range) 31 days
counts_by_status, timeline, top_domains, top_blocked, top_reasons, top_owners (read hourly totals) 400 days
tail (the newest rows under limit) no limit

A wider range is 422 range_too_large, with from named in errors. A query without from is not refused: its range starts that long before to (or now), so a bare search covers the last 31 days. The dashboard’s widest preset, 30 days, fits every shape. Cloud logs are kept at most 90 days anyway.

{
"type": "https://opdns.io/problems/range_too_large",
"status": 422,
"code": "range_too_large",
"detail": "search queries cover at most 31 days; narrow from and to",
"errors": [{ "field": "from", "message": "at most 31 days before to" }]
}

Cost and concurrency. Queries draw on a budget per organisation: two units per day of range for a query that reads raw rows (the 31-day shapes, and any shape filtered by more than time), one unit per week of range for one that reads hourly totals, one unit for a tail; the budget is refilled at 1,200 units a minute (about five 30-day Analytics page loads). At most four queries of an organisation run at once; another one waits up to five seconds for a slot. A query over budget, or still without a slot after five seconds, is 429 too_many_queries with Retry-After (seconds, also retry_after in the body): wait that long and retry.

Expensive queries. A cloud query that would read more rows (500 million) or memory (2 GiB) than one query may is stopped and answered 422 query_too_expensive: narrow the range or add filters, retrying the same query will not help.

These are the free plan’s defaults. They are counted per API server, not across the whole service. Values for paid plans are planned.

GET /v1/profiles/{id}/logs/stream (scope logs:read) is the live tail as server-sent events, with the filters of a tail log query:

  • event: rows with id: <cursor> and, as data, a query result holding the new rows. The first event carries the latest rows (up to limit) unless you resume. Events are sent only when there are rows.
  • event: error with a problem as data: a failure while streaming. The stream goes on after a transient one (node_offline, node_timeout, a 5xx) and ends after any other.
  • event: partial with {"partial": true, "partial_reason": "node_reconnecting"}: a poll came back partial with no rows (the node is reconnecting). Sent once until a complete poll; rows events carry their own partial flag.
  • a : heartbeat comment every 15 seconds. Each heartbeat re-checks your credential and the profile: a revoked session or token, or a deleted profile, ends the stream.

Reconnect with Last-Event-ID (browsers’ EventSource does it by itself) or ?cursor= set to the last event id to resume without gaps or repeats. The server checks the log destination every second and ends a stream after an hour; reconnect. An account may hold 4 streams at once per API server; beyond that the answer is 429 too_many_streams, and polling GET /v1/profiles/{id}/logs?mode=tail with cursor is the fallback the dashboard uses.

Terminal window
curl -N -H "Authorization: Bearer $OPDNS_TOKEN" \
'https://api.opdns.io/v1/profiles/abc123/logs/stream?status=blocked'

Log and analytics queries are answered from the profile’s log destination. For a self-hosted destination they are relayed to the node, which can be offline (503 node_offline with Retry-After), busy (503 node_busy, at most 4 queries in flight per node), slow (504 node_timeout, 10 seconds) or too old for a query shape (501 shape_unsupported).

POST /v1/profiles/{id}/list-reports (scope profiles:write) reports a domain that list list_id blocks but should not (kind false_positive) or misses (false_negative), with an optional note of up to 500 characters. The answer is 202 with the report’s id. The domain is stored in lower case without a trailing dot, with the list’s catalogue version at the time. An account may send 20 reports in 24 hours; the 21st is 429 too_many_reports with Retry-After. GET on the same path (scope profiles:read) lists the profile’s reports, newest first, with their status (open, accepted, rejected), the operators’ resolution_note and triage_due_at, the maintainers’ target for an answer: two business days (Monday to Friday, UTC) after the report. When the maintainers accept or reject a report, the reporter gets an email with the outcome and their note. Accepting a report changes no list by itself; see List reports.

Unauthenticated routes (sign-up, sign-in, the catalogue, enrolment, DDNS) are limited per client IP and answer 429 rate_limited with Retry-After (seconds, repeated as retry_after in the body). Per-token and per-organisation limits with RateLimit-* headers are planned; until then, keep scripts polite.

POST /v1/client-errors (a session, or a token with account:read) stores a scrubbed error report from the dashboard: message, stack, route, version and user agent, at most 32 KiB, 10 reports a minute per account. The first dashboard builds’ field names release, route and at are deprecated. Reports are kept 30 days and deleted with the account; operators list them with GET /v1/admin/client-errors. The dashboard’s own reporting can be turned off per browser (Error reports).