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, always with a
ts timestamp and a
latency_ms. Refusals always carry a reason.
- Idempotent. Every endpoint here is a read. Calling 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 | Override the caller IP for the allowlist check. Only honoured for server keys — a client key cannot tell us what its own address is. |
endpoint | string | optional | A label for your own logs. Appears in your usage breakdown so you can see which of your routes is spending allowance. |
Response
Authorised — 200
{
"authorized": true,
"key": "ak_live_xxxx…",
"domain": "example.com",
"scope": "read",
"expires_in": 1814400,
"entitlement": "eyJrIjoi…",
"entitlement_expires": 1788412800,
"signature": "9f2c…",
"ts": 1787990400,
"latency_ms": 7
}
Refused — 403
{
"authorized": false,
"reason": "domain_not_verified",
"message": "Prove you control this domain.",
"domain": "example.com",
"ts": 1787990400,
"latency_ms": 4
}
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. |
expires_in | integer | on success | Seconds until the key’s current window ends. |
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. |
signature | string | on success | HMAC over the response, covering your nonce. |
ts | integer | always | Server time when the answer was produced. |
latency_ms | integer | always | How long we took. Useful when something feels slow and you want to know whose fault it is. |
The entitlement token
A successful answer includes a signed, dated token good for several 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 over the response fields plus the nonce you sent. 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.
$expected = hash_hmac('sha256', $key . '|' . $domain . '|' . $nonce . '|' . $ts, $secret);
if (!hash_equals($expected, $response['signature'])) {
// Not from us, or altered in flight. 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 two requests. The first describes the visitor and gets a verdict; the second returns the answer
to the challenge, if one was issued.
1. Ask
POST /api/v1/botcheck.php
{
"key": "ak_live_xxxx",
"action": "check",
"signals": { … }
}
2. Answer the challenge
POST /api/v1/botcheck.php
{
"key": "ak_live_xxxx",
"action": "solve",
"nonce": "…",
"held_ms": 2500
}
| Name | Type | | Description |
key | string | required | The client key for the site being protected. |
action | string | required | check to ask, solve to answer a challenge. |
signals | object | optional | What the page observed about the visitor. Supplied by the snippet. |
nonce | string | on solve | The nonce from the challenge being answered. |
held_ms | integer | on solve | How long the page waited before answering. A script that answers instantly did not wait. |
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=solve. |
config | object | with challenge | How to run the challenge — hold_ms, and which device classes to allow. |
reason | string | on refusal | Why, e.g. bot_ua or missing_headers. |
token | string | on pass | Give this back on later checks to skip the challenge. |
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 | required | Which value to return. Omit it and you get every value the key may read. |
A successful response carries ok: true, the value, its type, and
updated_at so you can cache it sensibly. Refusals use the same reasons as verify.
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 | The API key. Its lock must match the domain, exactly as in verify. |
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.
Refusal reasons
Every refusal carries one of these in reason. Branch on this, not on the status code — a
403 covers five different situations with five different fixes.
| 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 body or query string. |
| 401 |
invalid_key |
The key does not exist, or was revoked. |
Check for a copy-paste truncation first. A revoked key never comes back — issue a new one. |
| 403 |
expired |
The key is past its window. |
Windows roll automatically on a live subscription, so this usually means billing rather than keys. |
| 403 |
ip_not_allowed |
The caller IP is not on the allowlist. |
Add it, or clear the allowlist. Your server’s egress IP is not your office IP. |
| 403 |
insufficient_scope |
The key lacks the scope the call asked for. |
Grant the scope, or stop sending scope. |
| 403 |
domain_mismatch |
The call came from a domain the key is not bound to. |
Call from that domain, or bind the key to the right one. |
| 403 |
domain_not_verified |
The domain is bound but ownership was never proved. |
Add the TXT record or file shown on the key’s page, then press Check. |
| 429 |
usage_limit_exceeded |
The account’s allowance for the cycle is spent. |
Pooled across all keys, resets on your anniversary. Upgrade for a bigger pool. |
| 429 |
rate_limited |
Too many calls from this key in one minute. |
Raise the per-minute limit on the key. Separate from the monthly allowance. |
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.
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.
Refused calls count towards both. A broken integration retrying an invalid key is spending your allowance,
and that is deliberate — it is the traffic you most want to notice.
| 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.