Developer guide
Everything you need to call the Capture Intelligence API: keys, the lookup, the answers, links, limits and errors.
Base URL: https://api.adminit.org.za. Every call carries two headers:
x-ci-key: <key_id>.<secret>, your API key.x-client-ref: <your reference>, required. Use 1 to 64 characters from A-Z a-z 0-9 . _ / - . Without it the API answers 400.
Start in the sandbox: it uses the same routes and shapes on fictional data, so you can build and test with no live quota used.
1. Get a test key
Sign in, open Keys and create a key of type test. The secret is shown once, so store it in a secret manager. A test key looks like ci-test-XXXXXXXXXX.<your secret> and works only on fictional data.
2. Your first lookup
POST /v1/lookup with the body {address, unit?, order_id?, view?}. The address goes in the body, never in the URL. A CV number alone is refused: order_id must come with the address it belongs to.
The view is one of full (the default), condition, features, disclosure, changes, insurance or bank.
curl
curl -X POST https://api.adminit.org.za/v1/lookup \
-H "x-ci-key: ci-test-XXXXXXXXXX.<your secret>" \
-H "x-client-ref: my-first-test" \
-H "content-type: application/json" \
-d '{"address": "1 Sandbox Street, Testville", "view": "condition"}'
Python (standard library only)
import json
import urllib.error
import urllib.request
KEY = "ci-test-XXXXXXXXXX.<your secret>" # load yours from a secret manager
BASE = "https://api.adminit.org.za"
def call(method, path, body=None):
req = urllib.request.Request(
BASE + path,
method=method,
data=json.dumps(body).encode() if body is not None else None,
headers={
"x-ci-key": KEY,
"x-client-ref": "my-first-test",
"content-type": "application/json",
},
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return resp.status, json.load(resp)
except urllib.error.HTTPError as err:
return err.code, json.load(err) # {"error": ..., "request_id": ...}
status, doc = call("POST", "/v1/lookup", {"address": "1 Sandbox Street, Testville"})
print(status, doc)
JavaScript
Plain Node script (Node 18 or later) on your server. The API does not answer browsers (no CORS); never put a key in a web page.
const KEY = "ci-test-XXXXXXXXXX.<your secret>"; // keep real keys on your server
async function main() {
const res = await fetch("https://api.adminit.org.za/v1/lookup", {
method: "POST",
headers: {
"x-ci-key": KEY,
"x-client-ref": "my-first-test",
"content-type": "application/json",
},
body: JSON.stringify({ address: "1 Sandbox Street, Testville" }),
});
const body = await res.json();
console.log(res.status, body.status, body);
}
main();
3. Reading the answers
A lookup that finds nothing is still HTTP 200. need_unit and no_report are not 4xx errors, so always branch on the status field in the body, not on the HTTP code. Every body also carries request_id, and sandbox bodies carry "sandbox": true.
status: match
observation holds the JSON for the view you asked for. It is cut down here; the full view has many more fields.
{
"status": "match",
"order_id": "CV900001",
"shoot_date": "2026-10-01",
"matched_address": "1 Sandbox Street, Testville",
"observation": {
"contract": "1.0",
"order_id": "CV900001",
"status": "complete",
"condition": {"grade": "good", "roof_type": "tiled", "storeys": 1},
"features": {"pool": {"status": "present", "confidence": "medium", "evidence": ["p02"]}}
},
"request_id": "...",
"sandbox": true
}
If a full observation is too large to return inline, observation falls back to the insurance view and a note field says so.
status: need_unit
The address has several units (sectional title). Send the same lookup again with unit.
{"status": "need_unit", "request_id": "...", "sandbox": true}
status: no_report
There is no released report for that address. We never guess a nearest match.
{"status": "no_report", "request_id": "...", "sandbox": true}
The link routes' answer
Both link routes answer 200 with the keys url and expires_in (in seconds, 300 for 5 minutes). Fetch the file from url within that time; do not send your key to it.
{"url": "https://...", "expires_in": 300, "request_id": "..."}
4. Report and photo links
GET /v1/orders/{cv}/report/insuranceandGET /v1/orders/{cv}/report/bankreturn a 5-minute signed link to the PDF.GET /v1/orders/{cv}/photos/{pNN}returns a 5-minute signed link to one report photo, for examplep01.
Links work only for CV numbers your organisation has matched by address. In the sandbox the sample PDFs and photos need no earlier match.
5. The sandbox cases
Six fictional properties, at "<n> Sandbox Street, Testville":
| CV | Case | The request that produces it (body of POST /v1/lookup) |
|---|---|---|
| CV900001 | match | {"address": "1 Sandbox Street, Testville"} |
| CV900002 | need_unit: sectional title, units 1 and 2 | {"address": "2 Sandbox Street, Testville"} answers need_unit; add "unit": "1" for a match. |
| CV900003 | no_report: the property has no released report (HTTP 200) | {"address": "3 Sandbox Street, Testville"} |
| CV900004 | match by CV number plus address | {"address": "4 Sandbox Street, Testville", "order_id": "CV900004"} |
| CV900005 | no_report: a CV number with a different address | {"address": "4 Sandbox Street, Testville", "order_id": "CV900005"}. The CV only fails when it comes with an address that is not its own: plain {"address": "5 Sandbox Street, Testville"} is a match. |
| CV900006 | match: a vacant plot | {"address": "6 Sandbox Street, Testville"} |
| (none) | 503: for testing retries | {"address": "503 Unavailable Street"} |
Sandbox answers carry "sandbox": true. "503 Unavailable Street" answers 503, so you can test your retries. Report and photo links work for CV900001, CV900002, CV900004, CV900005 and CV900006 with no earlier lookup.
6. Going live
There are three kinds of key. A test key never touches live data.
- Server: locked to your servers' IP addresses and made by your organisation's admins. Use this one in production.
- Personal: yours alone, not locked to an IP, with a lower daily cap.
- Test: sandbox only, fictional data.
Keys are shown once. Store them in a secret manager, never in source control. To rotate, pick an overlap on the Keys page: now, 24h or 7d. The old key keeps working until the overlap ends, so you can roll the new one out first.
7. Limits
- Live keys: 5 calls a second (bursts of 10) and 20,000 a month per organisation.
- Lookups: 300 a day per organisation by default. Personal keys are capped lower, 50 a day by default.
- Test keys: 2 calls a second (burst of 5), 5,000 a month, and 200 sandbox lookups a day.
A test key never uses up your live quota.
8. Errors
Every error body is {"error": "...", "request_id": "..."}. Quote the request_id if you ask Capture for help.
| Code | Meaning |
|---|---|
| 400 | Bad input. The message says what is wrong, for example a missing x-client-ref. |
| 401 | A bad, unknown or revoked key, or a suspended organisation (refused at the key check). |
| 403 | A server key used from an IP that is not on its list. A call with no x-ci-key or a malformed one is refused at the firewall, also with 403. A suspended organisation can also get 403 {"error": "forbidden"} at a lookup. |
| 404 | Not found. |
| 429 | daily lookup limit reached, daily sandbox limit reached, or too many requests. Slow down and try again later. |
| 503 | Temporarily unavailable. Retry with backoff. |
9. The firewall
A web application firewall sits in front of the API.
- Only callers in South Africa get through (the
api_countriessetting). Ask Capture before calling from another country, and we can add it. - Only a well-formed
x-ci-keyand paths under/v1/get through. - At most 1,500 calls in 5 minutes per IP address.
- Tor and VPN exits are refused. Data-centre IP addresses are fine.
Postman files are on the Downloads page.