Documentation
Everything from “what is an API” to the exact reason string the gateway returns when a request is refused. No account needed to read it.
What an API is
An API (Application Programming Interface) is how one piece of software asks another piece of software to do something. A web page is built for a human to read; an API is built for a program to call.
When your server calls an API it sends an ordinary HTTP request — the same protocol your browser uses — and gets back structured data instead of a page. Almost always that data is JSON: a format of names and values that any language can parse.
REQUEST POST https://api.example.com/v1/customers
{ "name": "Ada", "email": "ada@example.com" }
RESPONSE 201 Created
{ "id": "cus_8fH2", "name": "Ada", "created": 1767225600 }Three things make up nearly every API call:
- A method —
GETto read,POSTto create or submit,PUT/PATCHto change,DELETEto remove. The method tells the server your intent. - A URL — which resource you mean.
/v1/customers/cus_8fH2is “the customer with that id”. - Credentials — proof you are allowed. Usually an API key: a long random string that identifies your account.
The server answers with a status code. You will meet these constantly:
| Code | Family | Means |
|---|---|---|
200 | Success | It worked. 201 means something was created. |
400 | Your request | Malformed or missing something required. |
401 | Your request | Not authenticated — bad or absent credentials. |
403 | Your request | Authenticated, but not allowed to do this. |
404 | Your request | No such resource. |
429 | Your request | Too many calls. Slow down or wait for a reset. |
500 | Their server | Something broke on the other side. Retrying is reasonable. |
Rule of thumb. A 4xx is usually yours to fix; a 5xx is usually theirs. Retrying a 400 unchanged will fail forever — retrying a 500 often succeeds.
What BuildAPI does
BuildAPI is the layer that decides whether a given API key is allowed to do a given thing right now. You issue keys to your own customers or your own front ends, and every request is checked against one gateway before your code runs.
A single call to /api/v1/verify answers all of this at once:
- Does this key exist, and has it been revoked?
- Has it expired?
- Is the caller on the domain the key is bound to — and has that domain been proved?
- Is the caller IP allowed?
- Does the key hold the scope this endpoint requires?
- Is the account within its call allowance and its per-minute rate limit?
You get one authorized: true|false and, when false, a machine-readable reason. The call is also recorded, which is what the analytics and the quota are counted from.
Why a gateway rather than a check in your code? Because the check drifts. Two endpoints each doing their own validation is how one of them quietly ends up missing the expiry test, or the quota, or the domain lock. There is one chain here and every endpoint runs all of it.
Quick start
Create a key in the dashboard, bind it to a domain, then call the gateway from your server on each request you want to protect.
cURL
curl -s https://buildapi.app/api/v1/verify \ -d key=ak_live_xxxxxxxxxxxxxxxx \ -d domain=example.com \ -d scope=payments:read
PHP
$ch = curl_init('https://buildapi.app/api/v1/verify');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 5,
CURLOPT_POSTFIELDS => http_build_query([
'key' => getenv('BUILDAPI_KEY'),
'domain' => 'example.com',
'scope' => 'payments:read',
]),
]);
$res = json_decode(curl_exec($ch), true);
curl_close($ch);
if (empty($res['authorized'])) {
// $res['reason'] tells you exactly why — see the error table below.
http_response_code(403);
exit('Not authorized: ' . ($res['reason'] ?? 'unknown'));
}Node.js
const res = await fetch('https://buildapi.app/api/v1/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
key: process.env.BUILDAPI_KEY,
domain: 'example.com',
scope: 'payments:read',
}),
});
const data = await res.json();
if (!data.authorized) {
throw new Error(`Refused: ${data.reason}`);
}Python
import os, requests
r = requests.post('https://buildapi.app/api/v1/verify', data={
'key': os.environ['BUILDAPI_KEY'],
'domain': 'example.com',
'scope': 'payments:read',
}, timeout=5)
data = r.json()
if not data.get('authorized'):
raise RuntimeError(f"Refused: {data.get('reason')}")Never put a server key in front-end code. Anything in a browser is readable by anyone who opens developer tools. Server keys are stored hashed and shown once; client keys are the ones designed to be visible, and they are why domain locking exists.
API keys
A key is a long random string that identifies one integration on one domain. There are two kinds and the difference matters.
| Client key | Server key | |
|---|---|---|
| Lives in | Browser / mobile app | Your backend only |
| Visible to users | Yes, unavoidably | No, ever |
| Stored by us as | Value + hash | Hash only — shown once at creation |
| Protected by | The domain lock | Secrecy |
If you lose a server key you cannot recover it — rotate and replace. That is deliberate: a key we could show you again is a key an attacker with your dashboard session could show themselves.
Rotating
Rotation issues a new value and leaves the old one working until you revoke it, so you can deploy the new key before killing the old. Revoking is instant and permanent: a revoked key returns invalid_key forever.
Domain locking
Every key is bound to exactly one domain. On each call the gateway works out where the request actually came from — the domain parameter first, then the Origin header, then Referer — and refuses anything that does not match with domain_mismatch.
This is what makes a client key survivable. Somebody can read your key out of your JavaScript, but pasted into their own site it stops working, because their site is not your domain.
Subdomains are matched against the lock, so a key bound to example.com accepts app.example.com. A key bound to app.example.com does not accept the apex.
A domain lock is not a secret. It stops casual reuse, not a determined attacker who proxies through your own site. For anything valuable, verify server-side with a server key as well.
Proving you own the domain
Binding a key to example.com is a claim. Proving it stops somebody binding a key to a domain that is not theirs. You prove it once per domain, either way:
1. A DNS TXT record
In your DNS panel, open the zone and add a record with these four fields:
| Field | Value |
|---|---|
| Type | TXT |
| Name | _buildapi-verify — just this. Your panel adds the domain. |
| Value | buildapi-site-verification=<your token> |
| TTL | Auto, or 3600 |
The mistake almost everyone makes. Cloudflare, Namecheap, GoDaddy, Hostinger and Squarespace all append your domain to whatever you type in Name. Paste the full _buildapi-verify.example.com there and you create _buildapi-verify.example.com.example.com, which resolves to nothing and never verifies — while looking perfectly correct in your panel. Use the short name on its own.
2. Or a file
Serve the token as plain text at /.well-known/buildapi-verify.txt over HTTPS. Nothing else in the file.
Verification is re-proved periodically. Leave the record in place permanently. Removing it lapses the domain after three consecutive failed checks — one bad lookup never cuts anyone off — and a lapsed domain refuses every key bound to it with domain_not_verified.
A confirmation also expires on its own after 30 days without us being able to see the proof, so a verification cannot be trusted indefinitely on the strength of having once been true. A domain that is being re-checked normally is re-confirmed several times inside that window.
Moving a key to another domain
You can transfer a key to a different domain at any time. The key stops working the moment it moves and does not work again until the new domain is proved — the same setup a new key goes through. That, rather than a cooling-off period, is what stops one key serving several sites.
Scopes
Scopes narrow what a key may do, so a key that only reads cannot write. Send the scope your endpoint requires and the gateway refuses the call with insufficient_scope if the key lacks it.
| Scope | Grants |
|---|---|
payments:read | View payments & transactions (Payments) |
payments:write | Create & refund payments (Payments) |
accounts:read | View accounts & balances (Accounts) |
accounts:write | Move funds & manage accounts (Accounts) |
customers:read | View customers & profiles (Customers) |
customers:write | Create & update customers (Customers) |
analytics:read | Read usage & analytics (Platform) |
webhooks:manage | Manage webhook endpoints (Platform) |
A key with no scopes ticked has full access. That is the permissive default so nothing breaks on day one — but it means selecting scopes is something you have to do deliberately. Give each integration only what it needs.
Test and production keys
Every key belongs to one environment. Keep them separate: a test key in production is an outage, and a production key in a test suite is a bill.
- Test — for local work and CI. Safe to break.
- Production — real traffic, real quota.
Load whichever from an environment variable rather than committing it. A key in a git history is a key you have to rotate.
Limits, quotas and key validity
Two different limits
The monthly allowance governs the billing cycle. The per-minute rate limit is a burst guard that stops one runaway loop exhausting it in seconds. They are enforced separately and return different reasons — usage_limit_exceeded and rate_limited.
When the allowance resets
On your subscription anniversary, not the first of the month. Subscribe on the 20th and your cycle runs 20th to 20th. Paying yearly does not change this — a year buys twelve monthly allowances, not one large one.
The free tier is different: its allowance is a lifetime total, counted from the day the account was created, and it does not refill.
The allowance is one pool for the account
The published number is the account's total for the cycle, shared across every key you hold. Ten keys do not multiply it; they draw from the same pool, which is why the figure on the pricing page is the figure you get.
When the pool is spent, every key returns 429 together. The per-key rate limit is separate and still applies individually, so one noisy key can be throttled without affecting the rest.
How long a key lives
Keys on a paid plan are valid for 30 days, and they share one expiry anchored to your subscription rather than each counting down from when it was made. Create a key on the day you subscribe and it has 30 days; create another tomorrow and it has 29, because both end at the same moment.
That window rolls forward automatically while your subscription is live, so a yearly subscriber never re-issues keys. If a subscription lapses, keys run out their current window and stop.
Signed responses
An authorized reply carries an HMAC signature, keyed by the API key itself. Because only you and we know the key, only you and we can produce a valid signature — so your server can prove an authorized: true genuinely came from us and was not forged by something sitting in the middle.
Send a random nonce with the request and it is echoed back inside the signed payload. That is what makes a captured response impossible to replay later: a reply signed for one nonce will not validate against the next.
{
"authorized": true,
"reason": "ok",
"domain": "example.com",
"ts": 1767225600,
"expires_in": 120,
"nonce": "b3f1c9…",
"signature": "9d41…",
"latency_ms": 38
}Check the signature and check ts is recent — a response older than expires_in seconds should be treated as stale.
Staying up if we go down
Every authorized response includes an entitlement: a short-lived signed token saying this key was good as of now. Cache it. If the gateway is unreachable on a later request, validate the cached entitlement locally instead of failing the customer.
It expires after a few days, which is the point. A brief outage of ours does not take your site down, but a site cut off permanently does eventually stop — an entitlement that never expired would be a licence nobody could revoke.
The rule, exactly. An unreachable gateway is the only thing that may let a check pass unanswered, and only for as long as the cached entitlement is fresh. A gateway that answers and says no is a no — a refusal is never ridden out on a cached yes, and once the cache is older than the grace the answer becomes no as well. The client snippet we generate implements exactly this; if you write your own integration, match it.
Security scanning & testing
Beyond running your API, BuildAPI can assess the security posture of a domain you have verified. There are two tiers, and what separates them is not what they look at but how far they go.
The read-only scan — every plan
From Security scan in the dashboard, run a non-destructive audit of a domain you have proved you own. It reads your public DNS and email records (SPF, DKIM, DMARC, DNSSEC, MX and more) and fetches your site with ordinary GET requests to check HTTPS, TLS, security headers, cookie flags and whether anything sensitive is being served that should not be. It sends no payloads and changes nothing — it is a scan, not a penetration test — and you can save the result as a shareable PDF report or have it re-run on a schedule and be emailed only when something regresses.
Active testing — Enterprise
Active testing goes a step further: it sends real, active probes — an OPTIONS/TRACE to see which HTTP methods are exposed, cross-origin requests to catch a CORS policy that would hand your data to any website, and GETs of common leak locations (a served .env, an exposed .git, a stray database dump). It still performs no exploitation and sends nothing that alters or deletes data, and every run is bounded — a capped number of requests inside a fixed time budget.
Why active testing needs your authorization
This is the part that matters. Verifying a domain proves you control the name. It does not, on its own, give anyone the right to send active security traffic at the servers behind it — and that traffic leaves BuildAPI's own machines. So before any active run, an owner on the Enterprise plan signs a short, per-engagement authorization that pins an explicit scope and time window.
Your hosting provider's policy still applies. Your host, CDN or cloud (Hostinger, Cloudflare, AWS and the like) each have their own terms on security-testing the infrastructure they run, and owning the domain does not waive them. Obtaining any permission the provider requires is your responsibility — signing the authorization is where you confirm you have it.
An authorization can be revoked at any moment, ownership is re-checked at the instant of each run, runs are rate-limited per domain, and every run — whether it went ahead or was refused at the gate — is written to an audit trail you can read on the Active testing page. Active testing is available on the Enterprise plan.
Endpoints
POST /api/v1/verify
The main call. Runs the whole enforcement chain and records the usage.
| Parameter | Required | Notes |
|---|---|---|
key | Yes | The API key. |
domain | Recommended | Falls back to Origin, then Referer. Send it explicitly from a server. |
scope | No | Refuse unless the key holds this scope. |
ip | No | The end user's IP, when you are checking on their behalf. |
nonce | No | Echoed back inside the signature. Use one per request to prevent replay. |
Accepts JSON, form-encoded or query string.
POST /api/v1/content
Returns a piece of protected content only if the key passes the same checks — useful when the thing you are gating is the payload. Takes key, content_key and domain. Returns 404 if the key is valid but no such content exists.
POST /api/v1/botcheck
Scores a request as human or automated, for gating signups and forms.
Every error the gateway returns
Refusals carry a stable reason string. Branch on reason, never on the human-readable message — the message is written for people and may be reworded.
They are listed in the order the gateway checks them, so the first one that fails is the one you get back.
| HTTP | 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 |
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_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 |
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. |
An unknown key is not counted against you. Requests carrying a key we cannot recognise are refused without touching anybody's allowance — otherwise anyone who found your endpoint could burn a customer's quota for them. Refusals of keys we do recognise are recorded, because they are real traffic against a real key.
Going to production
- Keys come from the environment, never from source. Anything committed has to be rotated.
- Verify server-side. A check that runs in a browser can be edited by whoever is holding the browser.
- Branch on
reason.429deserves a retry after a wait;403 domain_mismatchdeserves an alert, because it means either a misconfiguration or somebody using your key elsewhere. - Set a timeout. Five seconds is generous. Decide now what happens if we do not answer — the cached entitlement is there for exactly that.
- Cache the entitlement so a short outage of ours is invisible to your users.
- Watch for the expiry warning. Keys roll automatically while a subscription is live; they stop when it lapses.
- One key per integration, not one shared everywhere. Rotating a shared key means redeploying everything at once.
Still stuck?
The Help Centre covers the problems people actually hit, with the fix for each. If it is not there, tell us and it will be.