Basics
All endpoints live under one base URL and speak JSON. There is no SDK to install — every call in this
reference is a single HTTP request you can make with curl.
Base URLhttps://buildapi.app/api/v1
- Method.
POST with a form body or JSON body. GET with a query
string works too and is convenient for testing; prefer POST in production so keys stay out of access logs.
- Encoding.
application/x-www-form-urlencoded or application/json.
Both are read; you do not need to say which.
- Responses. Always JSON. Every refusal carries a machine-readable
reason
and, from the enforcement chain, a message for people. Successful answers carry a
ts timestamp.
- Reads and writes.
POST /scan runs a fresh scan and
POST /assignments changes an assignment. Everything else here only reads, so calling it
twice does nothing twice.
Authentication
The key is the authentication. There is no separate token to exchange and no header to sign — you
pass key and the gateway answers.
What protects a key depends on which kind it is:
- Client keys (
ak_live_…) are visible in your page source. They are protected
by being locked to a verified domain, not by being secret. A client key called from a domain it is not
bound to is refused.
- Server keys are secrets. They have no domain to check, so keep them in an environment
variable and out of your repository.
A key is shown in full once, at creation. We store a hash, so we cannot show it to you again — rotate it if
you lose it.
POST /api/v1/verify.php
The main endpoint. Answers one question: may this key, from this place, do this thing, right now?
curl -s https://buildapi.app/api/v1/verify.php \
-d key=ak_live_xxxxxxxxxxxxxxxx \
-d domain=example.com \
-d nonce=$(openssl rand -hex 16)
Parameters
| Name | Type | | Description |
key | string | required | The API key. Sent in the body or as a query parameter. |
domain | string | recommended | The domain the call is being made for. Compared against the key’s lock. Omit it and a domain-locked key is refused with domain_mismatch. |
nonce | string | recommended | A value you generate per request. Echoed back inside the signature so a captured response cannot be replayed at you. Any random string; 16 bytes of hex is plenty. |
scope | string | optional | The capability this call needs, e.g. read. Checked against the key’s scopes. Omit it and no scope check is made. A key with no scopes selected has full access. |
ip | string | optional | The visitor’s address, when your server asks on a visitor’s behalf. Checked against the key’s IP allow and deny lists and country rules in place of the connecting address — unless IP binding is switched on, in which case the address the call actually comes from is what counts. |
install | string | optional | An identifier for this installation of your site. A key seen on a second installation waits for your approval (installation_pending), so a copied site does not run on your licence. |
Response
Authorised — 200
{
"authorized": true,
"reason": "ok",
"domain": "example.com",
"key": {"id": 42, "name": "Shop", "prefix": "ak_live_9f2c",
"environment": "production", "scopes": "full_access",
"domain": "example.com", "is_trial": false,
"expires_at": "2026-11-14 09:00:00", …},
"ts": 1787990400,
"expires_in": 120,
"signature": "9f2c…",
"latency_ms": 7,
"entitlement": "eyJrIjoi…",
"entitlement_expires": 1788595200,
"lifeline": "…",
"nonce": "3b1f…"
}
Refused — 403
{
"authorized": false,
"reason": "domain_not_verified",
"message": "This domain has not been verified. …",
"key": {"id": 42, "name": "Shop", …}
}
A refusal names the key only when it is one of ours; an unknown key gets just the reason.
Fields
| Field | Type | | Meaning |
authorized | boolean | always | The answer. Branch on this, then on reason. |
reason | string | on refusal | Machine-readable cause. See refusal reasons. |
message | string | on refusal | A sentence for a human. Do not parse it — the wording changes; reason does not. |
key | object | usually | The key’s public details: id, name, prefix, environment, industry, scopes (or full_access), domain, is_trial, expires_at. Never the key itself. |
domain | string | on success | The domain the answer is for. Part of the signature. |
ts | integer | on success | Server time when the answer was produced. Part of the signature. |
expires_in | integer | on success | How many seconds this answer stays fresh (120). Treat one whose ts is older than that as stale. |
signature | string | on success | HMAC-SHA256 keyed with your API key, covering ts, the key’s id, the answer, the domain and your nonce. See below. |
nonce | string | if you sent one | Your nonce, echoed back. |
latency_ms | integer | on success | How long we took. Useful when something feels slow and you want to know whose fault it is. |
entitlement | string | on success | A signed token you can cache and re-check offline. See below. |
entitlement_expires | integer | on success | Unix time the token stops being valid (7 days on). |
lifeline | string | on success | A fallback for the day the service itself is gone, bound to this installation. Keep it with the entitlement. |
browser_guard | string | client keys | The page snippet for this key, ready to use. Never sent for a server key. |
firewall | object | server calls | The key’s request rules, when it has any. Only to a server, never to a browser. |
notice | string | sometimes | A message for an outdated client library. |
The entitlement token
A successful answer includes a signed, dated token good for 7 days. Cache it. Two things follow:
- Fewer round trips. Re-check the token locally rather than calling us on every request.
- You survive our outage. If we are unreachable, keep serving on the token you hold until
it expires.
A refusal is not an outage. If we answer and say no, close the gate now. If we do not
answer at all, fall back to the cached token. Conflating those two means a revoked key keeps working for
days — which is the one thing revocation exists to prevent.
Verifying the signature
The signature is an HMAC-SHA256, keyed with your API key, over the answer’s ts, the
key’s id, a 1 for “authorized”, the domain and the
nonce you sent, joined with dots. Checking it proves the answer you are holding is the one we gave, and that
it is not an older answer replayed.
If the response never leaves the request that fetched it, TLS alone is close enough. The moment you cache it
— and you should — check the signature, because a cached answer is exactly what is worth tampering with.
// $r is the decoded response, $apiKey your key, $nonce what you sent (or '').
$msg = $r['ts'] . '.' . $r['key']['id'] . '.1.' . $r['domain'] . ($nonce !== '' ? '.' . $nonce : '');
$expected = hash_hmac('sha256', $msg, $apiKey);
if (!hash_equals($expected, $r['signature']) || abs(time() - $r['ts']) > $r['expires_in']) {
// Not from us, altered in flight, or too old. Refuse.
}
POST /api/v1/botcheck.php
Asks whether a visitor looks like a browser. Enabled per key in the dashboard; when it is off this endpoint
answers {"enforced": false, "allow": true} and does nothing else, so you can leave the call in
place and toggle the feature without deploying.
The snippet on your page is what calls this. Turning the setting on without installing the snippet means
nothing is ever asked about anything.
The two calls
A check is up to two requests. The first describes the visitor and gets a verdict; the second answers the
challenge, if one was issued.
1. Ask
POST /api/v1/botcheck.php
{
"key": "ak_live_xxxx",
"action": "assess",
"signals": { … }
}
2. Answer the challenge
POST /api/v1/botcheck.php
{
"key": "ak_live_xxxx",
"action": "grant",
"nonce": "…",
"held_ms": 2500
}
| Name | Type | | Description |
key | string | required | The client key for the site being protected. |
action | string | optional | assess to ask (the default), grant to answer a challenge. |
signals | object | optional | What the page observed about the visitor. Supplied by the snippet. |
nonce | string | on grant | The nonce from the challenge being answered. It lasts two minutes (expired_challenge after that). |
held_ms | integer | on grant | How long the visitor held before answering. Shorter than the key’s hold time is hold_too_short. |
token | string | optional | A token from a previous pass, so a returning visitor is not challenged again. |
Response
| Field | Type | | Meaning |
enforced | boolean | always | Whether bot protection is on for this key. false means every other field is advisory. |
allow | boolean | always | Let this visitor through. |
challenge | boolean | sometimes | Present when the visitor should be challenged rather than allowed or refused outright. |
nonce | string | with challenge | Return this with action=grant. |
config | object | with challenge | How to run the challenge: hold_ms, and allow_desktop, allow_tablet, allow_mobile. |
reason | string | on refusal | Why, e.g. bot_ua, hold_too_short, domain_mismatch or invalid_key. |
token | string | on grant | Give this back on later checks to skip the challenge. |
verified | boolean | with a good token | true when a returning visitor’s token was accepted. |
blocked_url | string | optional | Where to send a refused visitor, if you configured one. |
GET /api/v1/content.php
Fetches a value you manage in the dashboard, gated by the same key rules as verify. Useful for
a message, a feature flag or a price you want to change without deploying.
curl -s "https://buildapi.app/api/v1/content.php?key=ak_live_xxxx&domain=example.com&content_key=banner"
| Name | Type | | Description |
key | string | required | The API key. |
domain | string | recommended | Checked against the key’s lock, exactly as in verify. |
content_key | string | optional | Which value to return. Omit it and you get every value the key may read. One that does not exist is 404 not_found. |
A successful response is {"ok": true, "domain", "content", "ts", "signature", "latency_ms"}.
content is keyed by content key, each with its type, value (JSON values
come back as JSON) and updated_at. The signature is HMAC-SHA256, keyed with your API
key, over ts, the domain and the JSON of content, joined with dots. Refusals carry
ok: false and the same reasons as verify.
POST /api/v1/secret.php
Hands your server a value it cannot run without — typically the key that decrypts one column of your own
database — so a copy of your files and your data is useless without a working licence. You create the
secrets under Dashboard → Runtime Secrets.
Server only. The answer is the secret itself, so the endpoint sends no CORS headers and a
browser cannot read it. The key must have an IP allowlist (ip_binding_required otherwise), and
only POST is accepted.
curl -s https://buildapi.app/api/v1/secret.php \
-d key=ak_live_SERVER_KEY \
-d name=db_field_key \
-d domain=example.com \
-d nonce=$(openssl rand -hex 16)
| Name | Type | | Description |
key | string | required | The key, bound to your server’s address. |
name | string | required | Which secret. Missing is 400 missing_name; one that does not exist is 404 unknown_secret. |
domain | string | recommended | Checked against the key’s lock, exactly as in verify. |
nonce | string | recommended | Echoed back and covered by the signature, as in verify. |
Returns {"ok": true, "name", "value", "ts", "signature"} (plus nonce if you sent
one), signed exactly like a verify answer. Never cache it on disk; hold it in memory.
GET/POST /api/v1/scan.php
Runs and fetches the same read-only security scan the dashboard shows — DNS and
email deliverability, plus web-security posture — from your own tooling. It only ever scans a domain
your organization has verified, and it is non-destructive: active testing is never exposed here.
# the latest stored result (no new traffic)
curl -s "https://buildapi.app/api/v1/scan.php?key=ak_live_xxxx&domain=example.com"
# run a fresh scan
curl -s -X POST https://buildapi.app/api/v1/scan.php -d key=ak_live_xxxx -d domain=example.com
# every verified domain and its latest grades
curl -s "https://buildapi.app/api/v1/scan.php?key=ak_live_xxxx&domain=example.com&list=1"
# a domain's score history (the trend)
curl -s "https://buildapi.app/api/v1/scan.php?key=ak_live_xxxx&domain=example.com&history=1"
list=1 returns a domains array — one entry per domain your organization has
verified, each with its latest web, dns and active grade (or
null if never scanned). history=1 returns a history object with a
web, dns and active series of {score, grade, fail_count,
at} points, oldest first — the same trend the dashboard draws. Both are cheap reads.
| Name | Type | | Description |
key | string | required | A server (secret) key. Its lock must match the domain, exactly as in verify. A client key is refused with server_key_required — client keys sit in your pages, and findings are a map of your weaknesses. |
domain | string | required | The domain to scan. Must be one your organization has verified; otherwise the call is refused. |
GET returns the last stored scan, or 404 no_scan if none has run yet.
POST runs a fresh scan, stores it and returns it. A successful response carries
ok: true, fresh, and a web and dns object, each with a
grade, score, fail_count, warn_count and a
findings array (id, group, label, status,
summary, detail, evidence). Each finding also carries
acknowledged (true when your team has accepted that risk and the acceptance is
still active) and, when a decision exists, an acknowledgement object
(note, acked_at, expires_at, expired) — so your own
tooling can reflect an accepted risk rather than re-flagging it.
Because a fresh scan makes real outbound requests to the target, POST has its own tight
limit — at most 10 fresh scans per hour per organization (429 scan_rate_limited). GET is
cheap; fetch the stored result as often as you like. Every response carries
X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (a
Unix timestamp) for that budget, so a client can pace itself.
GET /api/v1/events.php
A feed of your organization’s security events for a SIEM (Splunk, Elastic, …) to poll:
every stored scan report, every regression monitoring finds, and every accepted risk accepted, released,
nearing or past its review date. Events are kept for 90 days, oldest first.
Rather not run a poller? Under Dashboard → Portfolio → SIEM export
we push the same events to a Splunk HTTP Event Collector, an Elasticsearch _bulk endpoint or Datadog Logs
as they happen — at-least-once, with retries, and with a fixed Elastic _id per event so
a retry never duplicates one.
# newline-delimited JSON, from the start
curl -s "https://buildapi.app/api/v1/events.php?key=ak_live_xxxx&domain=example.com"
# only what is new since your last poll (pass back X-Next-Cursor)
curl -s "https://buildapi.app/api/v1/events.php?key=ak_live_xxxx&domain=example.com&since=1842"
# RFC 5424 syslog lines instead
curl -s "https://buildapi.app/api/v1/events.php?key=ak_live_xxxx&domain=example.com&format=syslog"
| Name | Type | | Description |
key | string | required | A server (secret) key; the feed is scoped to its organization. A client key is refused with server_key_required. |
domain | string | recommended | The key’s domain, checked against its lock exactly as in verify (a poller sends no Origin). The feed covers every domain of the organization either way. |
since | integer | optional | Return events after this id. Pass back the last X-Next-Cursor and you never miss or repeat one. Default 0. |
limit | integer | optional | Events per call, 1–1000. Default 500. X-Has-More: 1 means call again. |
type | string | optional | Only one type: scan.completed, scan.regression, risk.accepted, risk.unaccepted, risk.expiring, risk.expired, finding.assigned, finding.reopened, finding.overdue, finding.near_target, finding.escalated or finding.status_changed. |
format | string | optional | ndjson (default, application/x-ndjson) or syslog (RFC 5424, severity by type: regressions, expired risks, and reopened, overdue or escalated findings are warning). |
Each NDJSON line is an object with id, type, time (UTC) and
domain, plus the event’s own fields — e.g. kind, grade,
score, fail_count on scan.completed, or new_issues and
resolved on scan.regression. The finding.* events come one per finding, with
finding_key, finding (its name), owner ({name, email} or null),
status and due_at — plus by and automatic on
finding.assigned, target_date on finding.near_target, and
days_overdue on finding.escalated, and from (the previous status) with by on finding.status_changed.
GET /api/v1/assignments.php
Who owns which security finding — the same list as the dashboard’s Assignments page —
for your own tracker or reports. Overdue first, then by due date. Server key only.
# open and in-progress findings, overdue first
curl -s "https://buildapi.app/api/v1/assignments.php?key=ak_live_SERVER_KEY&domain=example.com"
# one person's overdue work on one domain
curl -s "https://buildapi.app/api/v1/assignments.php?key=ak_live_SERVER_KEY&domain=example.com&owner=ana@example.com&overdue=1&for_domain=shop.example.com"
| Name | Type | | Description |
key | string | required | A server (secret) key. A client key is refused with server_key_required. |
domain | string | recommended | The key’s domain, checked against its lock (a backend sends no Origin). |
status | string | optional | open, in_progress, done or all. Default: open and in progress. |
for_domain | string | optional | Only findings on this scanned domain. |
owner | string | optional | Only this member’s findings, by email. Not a member → 400 unknown_owner. |
overdue | 1 | optional | Only findings past their due date. |
Returns {"ok":true,"count":N,"assignments":[…]}; each has domain,
finding_key, finding, status, owner
({name, email} or null), due_at, overdue,
past_target (open longer than your fix-time target since a scan found it) and
updated_at. At most 500.
GET …&history=1 — one finding’s story
curl -s "https://buildapi.app/api/v1/assignments.php?key=ak_live_SERVER_KEY&domain=example.com&history=1&for_domain=shop.example.com&finding_key=dns:spf"
Returns the assignment, its history (time, who, what, oldest first —
what people did and what happened on its own: reopened, overdue, escalated…) and its notes
(time, who, body). 404 not_found unless that finding is assigned in your organization.
POST /api/v1/assignments.php — update one from your own tracker
# give a finding an owner and a due date (or change its status)
curl -s -X POST "https://buildapi.app/api/v1/assignments.php" \
-H "Content-Type: application/json" \
-d '{"key":"ak_live_SERVER_KEY","domain":"example.com","for_domain":"shop.example.com",
"finding_key":"dns:spf","owner":"ana@example.com","status":"in_progress","due":"2026-11-30"}'
for_domain (a domain your organization has verified) and finding_key are required.
owner is a member’s email (empty: nobody), status is open,
in_progress or done, and due is a date from today on (empty: clear).
Anything you leave out stays as it is. Returns {"ok":true,"assignment":{…}} in the shape above.
The usual emails, webhooks and events follow, and the change is on your team activity record, marked as
made through the API. Errors: unknown_domain, bad_finding_key, unknown_owner,
unknown_status, bad_due, due_in_past; at most 120 changes an hour.
Send "recheck":"1" (with for_domain and finding_key) instead to re-scan that finding now:
you get passed and check_status, and a passing finding is marked done (30 re-checks an hour).
Server key only.
GET /api/v1/activity.php
Your team’s security activity record — risks accepted and released, findings assigned and moved,
monitoring and scan settings, trust pages, the SIEM export, evidence downloads, membership changes —
newest first. The same record as the Team activity page and every evidence pack. Never includes IP addresses.
Server key only.
# the latest 100 actions
curl -s "https://buildapi.app/api/v1/activity.php?key=ak_live_SERVER_KEY&domain=example.com"
# risk decisions in Q3, 500 a page
curl -s "https://buildapi.app/api/v1/activity.php?key=ak_live_SERVER_KEY&domain=example.com&group=risks&from=2026-07-01&to=2026-09-30&limit=500"
| Name | Type | | Description |
key | string | required | A server (secret) key. A client key is refused with server_key_required. |
domain | string | recommended | The key’s domain, checked against its lock. |
from / to | YYYY-MM-DD | optional | Inclusive date range. A malformed date is 400 bad_from / bad_to. |
group | string | optional | risks, ownership, monitoring, settings, trust, siem, evidence, members, team_security. Anything else is 400 unknown_group. |
for_domain | string | optional | Only actions about this scanned domain. |
limit / page | integer | optional | Page size (1–500, default 100) and page number. has_more says whether to ask for the next page. |
Returns {"ok":true,"total":N,"page":P,"has_more":bool,"activity":[…]}; each has
time (UTC), who, action (in words), action_code
(e.g. risk_accepted), domain and detail.
GET /api/status.php
The status page as JSON, for your own dashboards and monitors: the overall indicator,
each component, 30-day uptime per service, open and recent incidents, and maintenance in progress or ahead.
No key and nothing personal in it; readable from any origin (CORS *), cached for
60 seconds, read-only (anything but GET is 405). It reports exactly what the page shows.
curl -s "https://buildapi.app/api/status.php"
// in a browser
const s = await (await fetch("https://buildapi.app/api/status.php")).json();
if (s.status.indicator !== "ok") showBanner(s.status.description);
| Field | Type | | Description |
status.indicator | string | always | ok, degraded, down or maintenance (planned work running, nothing worse). status.description says it in words. |
components[] | array | always | Each part of the platform: name and status — operational, degraded, outage or not_configured. |
uptime_30d | object|null | always | From the outside checks: overall_pct, checks, and services — website, api, dashboard, each with uptime_pct, checks and typical_ms. null if it cannot be read. |
incidents.open[] / recent[] | array | always | Open ones, and those resolved in the last 14 days: id, title, impact (minor/major), status, affects (service keys; empty means everything), created_at, resolved_at, postmortem (the write-up of what happened and what changed, or null), and updates newest first. |
maintenance.in_progress[] / upcoming[] | array | always | id, title, body, affects, starts_at, ends_at. |
page | object | always | name, url of the status page, and generated_at. |
Times are ISO 8601 in UTC. A trimmed example, during an incident:
{
"page": {"name": "BuildAPI", "url": "https://buildapi.app/status.php", "generated_at": "2026-10-09T08:12:40Z"},
"status": {"indicator": "degraded", "description": "Partial service degradation"},
"components": [{"name": "API Gateway", "status": "degraded"}, {"name": "Database", "status": "operational"}],
"uptime_30d": {"overall_pct": 99.93, "checks": 1440,
"services": {"api": {"name": "API", "uptime_pct": 99.86, "checks": 1440, "typical_ms": 131}}},
"incidents": {"open": [{"id": 12, "title": "Slow key verification", "impact": "minor", "status": "identified",
"affects": ["api"], "created_at": "2026-10-09T07:40:00Z", "resolved_at": null, "postmortem": null,
"updates": [{"status": "identified", "body": "A database index was missing; adding it now.", "at": "2026-10-09T07:58:00Z"},
{"status": "investigating", "body": "Some verify calls take several seconds.", "at": "2026-10-09T07:40:00Z"}]}],
"recent": []},
"maintenance": {"in_progress": [], "upcoming": []}
}
Also available: the same history as an Atom feed, a
printable quarterly report, and a badge for your own site —
/status-badge.php (SVG; ?lang=fr for French) shows operational,
degraded, outage or maintenance, coloured to match:
<a href="https://buildapi.app/status.php"><img src="https://buildapi.app/status-badge.php" alt="BuildAPI status" height="20"></a>
Refusal reasons
Every refusal from the enforcement chain carries one of these in reason. Branch on this, not
on the status code — a 403 covers 14 different situations with different fixes.
Endpoints add a few of their own, named in their sections above (not_found,
no_scan, unknown_secret, scan_rate_limited…).
| Status | Reason | What happened | What to do |
| 400 |
missing_key |
No key was sent at all. |
Send the key as a key parameter in the POST body or query string. |
| 401 |
invalid_key |
The key does not exist, or it has been revoked. |
Check for a copy-paste truncation. A revoked key never comes back — issue a new one. |
| 403 |
key_suspended |
The key is paused — by you on the API Keys page, or by us. |
If you paused it, resume it from the key's page; if we did, contact support. It comes back as it was, and calls refused meanwhile are not charged. |
| 403 |
expired |
The key is past its expiry date. |
Keys run to the end of the current 30-day window. If the subscription is live the window rolls automatically; if it lapsed, renew and the key reactivates. |
| 403 |
ip_blocked |
The caller IP is on the key's deny list. |
Remove the address from the deny list if it should be allowed. A deny always wins over the allowlist. |
| 403 |
ip_not_allowed |
The caller IP is not in the key's allowlist. |
Add the IP in the dashboard, or clear the allowlist to accept any address. Remember your server's egress IP is not your office IP. |
| 403 |
geo_blocked |
The call came from a country the key blocks. |
Change the key's country rule if that country should be allowed. |
| 403 |
geo_not_allowed |
The call came from a country outside the key's allowed list. |
Add the country to the key's allowed list. An address we cannot place in a country is let through, not refused. |
| 403 |
insufficient_scope |
The key lacks the scope the endpoint asked for. |
Tick the scope on the key, or stop sending scope if you do not need the check. A key with no scopes selected has full access. |
| 403 |
domain_mismatch |
The request came from a domain the key is not bound to. |
Every key is locked to one domain. Either call from that domain, or bind the key to the right one. |
| 403 |
domain_not_verified |
The domain is bound but ownership has never been proved. |
Add the TXT record or the file shown on the key's page, then press Check. Only needs doing once per domain. |
| 429 |
usage_limit_exceeded |
The account has spent its call allowance for the current cycle. |
The allowance refills on your subscription anniversary, not the 1st. It is pooled across all your keys, so adding keys does not add allowance — upgrade for a bigger pool. |
| 429 |
rate_limited |
Too many calls from this key in one minute. |
Set or raise the per-minute limit on the key. This is a burst guard, separate from the monthly allowance. |
| 403 |
installation_pending |
The key is running on a second installation that is waiting for your approval. |
Approve or block it on the key's page. The first installation a key is seen on keeps working. |
| 403 |
installation_blocked |
The key is running on an installation you blocked. |
Approve it on the key's page if it is yours. |
| 403 |
server_key_required |
The endpoint returns security data and was called with a client key. |
Create a server (secret) key and call it from your backend. Client keys are published in your pages by design. |
| 403 |
ip_binding_required |
Secrets are only released to a key bound to your server's address, and this key has no IP allowlist. |
Add your server's IP to the key's allowlist. |
| 403 |
origin_mismatch |
Protected content was asked for from a page other than the one it is published on. |
Request it from the page the content belongs to. |
Status codes
200 — the call was answered. Read authorized; it may still be
false.
400 — the request was malformed. Usually a missing key.
401 — the key is not one of ours, or no longer is.
403 — the key is real and this call is not permitted. Read reason.
404 — the thing asked for does not exist: a content key, a secret, a stored scan.
429 — a limit was hit, either the burst rate or the cycle allowance.
5xx — our problem. Treat as unreachable, not as a refusal: fall back to your cached
entitlement token.
Rate limits and quotas
Two separate limits, often confused:
- Per-minute rate limit — set per key, a burst guard. Exceeding it returns
rate_limited and clears within the minute.
- Cycle allowance — set by your plan, pooled across every key on the account, reset on your
subscription anniversary. Exceeding it returns
usage_limit_exceeded until the reset or an
upgrade.
A refused call with one of your keys counts against your allowance — a broken integration retrying is
traffic you want to notice — except these, which are not your doing or would count twice:
key_suspended, usage_limit_exceeded, rate_limited, server_key_required, ip_binding_required, origin_mismatch.
A key we do not recognise belongs to no account, so it counts against nobody.
| Plan | Calls per cycle | Keys |
| Free |
1,000 |
1 |
| Enterprise |
Unlimited |
Unlimited |
| Starter |
300,000 |
3 |
| Professional |
2,500,000 |
15 |
Versioning
The version is in the path: /api/v1/. Within a version we will add response fields and add
optional parameters, and we will not remove a field, change what one means, or make an optional parameter
required.
Write your client so an unrecognised field is ignored rather than fatal. If we ever need a breaking change
it will be /api/v2/, and v1 will keep working while anyone is using it.
Changes are listed in the changelog.