API Reference

Three endpoints. Every parameter, every field, every reason a call can be refused.

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

NameTypeDescription
keystringrequiredThe API key. Sent in the body or as a query parameter.
domainstringrecommendedThe 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.
noncestringrecommendedA 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.
scopestringoptionalThe 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.
ipstringoptionalOverride the caller IP for the allowlist check. Only honoured for server keys — a client key cannot tell us what its own address is.
endpointstringoptionalA 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

FieldTypeMeaning
authorizedbooleanalwaysThe answer. Branch on this, then on reason.
reasonstringon refusalMachine-readable cause. See refusal reasons.
messagestringon refusalA sentence for a human. Do not parse it — the wording changes; reason does not.
expires_inintegeron successSeconds until the key’s current window ends.
entitlementstringon successA signed token you can cache and re-check offline. See below.
entitlement_expiresintegeron successUnix time the token stops being valid.
signaturestringon successHMAC over the response, covering your nonce.
tsintegeralwaysServer time when the answer was produced.
latency_msintegeralwaysHow 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
}
NameTypeDescription
keystringrequiredThe client key for the site being protected.
actionstringrequiredcheck to ask, solve to answer a challenge.
signalsobjectoptionalWhat the page observed about the visitor. Supplied by the snippet.
noncestringon solveThe nonce from the challenge being answered.
held_msintegeron solveHow long the page waited before answering. A script that answers instantly did not wait.
tokenstringoptionalA token from a previous pass, so a returning visitor is not challenged again.

Response

FieldTypeMeaning
enforcedbooleanalwaysWhether bot protection is on for this key. false means every other field is advisory.
allowbooleanalwaysLet this visitor through.
challengebooleansometimesPresent when the visitor should be challenged rather than allowed or refused outright.
noncestringwith challengeReturn this with action=solve.
configobjectwith challengeHow to run the challenge — hold_ms, and which device classes to allow.
reasonstringon refusalWhy, e.g. bot_ua or missing_headers.
tokenstringon passGive this back on later checks to skip the challenge.
blocked_urlstringoptionalWhere 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"
NameTypeDescription
keystringrequiredThe API key.
domainstringrecommendedChecked against the key’s lock, exactly as in verify.
content_keystringrequiredWhich 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.

NameTypeDescription
keystringrequiredThe API key. Its lock must match the domain, exactly as in verify.
domainstringrequiredThe 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.

StatusReasonWhat happenedWhat 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.

PlanCalls per cycleKeys
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.

Try it now

The playground runs these calls against your own key, in your browser.

Get a key