Developers
The same report, from your own systems
A law firm or an insolvency practice that checks directors every week should not have to do it one browser tab at a time. The API starts checks from your case system and collects the reports when they are ready, under the same rules as the app: one credit a report, the identity step never skipped, a legitimate business interest asserted for each person, and every report private to your organisation.
At a glance
What you are working with
- Base URL
https://checkthedirector.co.uk/api/v1- Authentication
Authorization: Bearer ctd_…, a key created by your organisation's account holder under Settings → API.- Format
- JSON in and out, UTF-8. Dates are ISO 8601 in UTC.
- Price
- As in the app: one credit a report, from the organisation's shared credits. Nothing is spent until you confirm a check.
- Limits
- 60 requests a minute and 600 an hour per key. Checks are also bounded by your credits.
- Version
- 1.0.0. The path is versioned (
/api/v1); fields are only ever added within a version. - Reference
- OpenAPI 3.1, generated from the schemas the endpoints validate requests with, so request formats cannot drift from the code.
Quickstart
A first report in four calls
Start a check, confirm the records, wait for the report, download the PDF. The example uses a fictional name; replace the key and the ids with your own.
1Start the check: who do you mean?
Send the name as you know it and, for a common name, a company number they are or were an officer of. You assert a legitimate business interest in the body; nothing is spent.
curl -X POST https://checkthedirector.co.uk/api/v1/checks \ -H "Authorization: Bearer ctd_your_key" \ -H "Content-Type: application/json" \ -d '{ "name": "Jane Exemplar", "companyNumber": "01000001", "reference": "MAT-2026/041", "interest": true }'HTTP 201 { "check": { "id": "chk_01J9…", "status": "open", "expiresAt": "2026-10-23T14:02:11.000Z" }, "candidates": [ { "officerId": "1kr721zGSt9RLbqMqfxhSjzjx1E", "name": "EXEMPLAR, Jane Mary", "birth": { "month": 3, "year": 1975 }, "appointments": 4, "companyNumbers": ["01000001", "01000007"], "confidence": "ANCHOR", "reasons": ["Appointed at the company you named"], "proposed": true }, { "officerId": "9hPq2sVmT4wXyBcDfGhJkLmNoP0", "name": "EXEMPLAR, Jane", "birth": { "month": 11, "year": 1982 }, "appointments": 1, "companyNumbers": ["01000012"], "confidence": "CONFLICT", "reasons": ["Different month and year of birth"], "proposed": false } ], "control": { "status": "searched", "snapshotDate": "2026-10-21", "truncated": false, "entries": [ … ] }, "trusteeships": { "status": "searched", "extractDate": "2026-10-21", "truncated": false, "entries": [ … ] } }2Confirm the records: this is the person
Send the officer ids you confirm. The response states the size of the reading and the credits it will take, spends them, and queues the report. An
Idempotency-Keymakes a retried call return the first answer rather than paying twice.curl -X POST https://checkthedirector.co.uk/api/v1/checks/chk_01J9…/confirm \ -H "Authorization: Bearer ctd_your_key" \ -H "Idempotency-Key: 7f2c0d6e-…" \ -H "Content-Type: application/json" \ -d '{ "officerIds": ["1kr721zGSt9RLbqMqfxhSjzjx1E"] }'HTTP 201 { "subject": { "id": "sub_01J9…", "name": "Jane Exemplar", "kind": "person", "companyNumber": null, "officerIds": ["1kr721zGSt9RLbqMqfxhSjzjx1E"], "excludedOfficerIds": [], "sizing": "sized" }, "size": { "companies": 4, "filings": 31, "pages": 140 }, "quote": { "standard": true, "blocks": 0, "credits": 1 }, "report": { "id": "rpt_01J9…", "version": 1, "status": "queued", "subjectId": "sub_01J9…", "reference": "MAT-2026/041", "reading": "standard", "creditsSpent": 1, "createdAt": "2026-10-22T14:03:40.000Z", "completedAt": null, "dataRetrievedAt": null, "failureReason": null }, "included": { "officerIds": ["1kr721zGSt9RLbqMqfxhSjzjx1E"], "controlKeys": [], "trusteeshipKeys": [] }, "excluded": { "officerIds": ["9hPq2sVmT4wXyBcDfGhJkLmNoP0"] } }3Wait for the report
Register a webhook once and we tell you when it is ready; or poll the report, which answers with its progress until then. A report is not instant: the filings are downloaded and read first.
curl https://checkthedirector.co.uk/api/v1/reports/rpt_01J9… -H "Authorization: Bearer ctd_your_key" HTTP 200 { "report": { "id": "rpt_01J9…", "version": 1, "status": "running", … }, "progress": { "completed": 7, "total": 14, "documents": 31, "steps": [ { "name": "gatherAppointments", "label": "Finding every appointment on the confirmed records", "state": "done" }, … { "name": "extractText", "label": "Reading the filings", "state": "running" }, { "name": "extractFacts", "label": "Extracting facts and their sources", "state": "waiting" }, … ], "current": { "step": "extractText", "done": 12, "total": 31, "secondsLeft": 95 } } }When the status is
ready, the same call returnsmodel: the whole report as JSON, described under Reports below.4Download the PDF
curl -L https://checkthedirector.co.uk/api/v1/reports/rpt_01J9…/pdf -H "Authorization: Bearer ctd_your_key" -o report.pdf
The endpoint answers 303 with a signed link that is valid for five minutes; follow it without the Authorization header. The download is audited as an export, as it is in the app.
Authentication
Keys
Your organisation's account holder creates keys under Settings → API, each with a name and an optional expiry. The key is shown once; we keep only a hash. Send it as a bearer token on every request:
Authorization: Bearer ctd_…
A key acts as the member who created it, inside their organisation, and can do nothing that member could not do in the app. If that member leaves the organisation, the key stops working. The account holder revokes keys at any time; creation and revocation are in the organisation's activity log. Treat a key as a password: keep it server-side, never in a browser or a repository.
The identity step
Who you are checking is your decision
Companies House holds officer records, not people. The same person often has several records, and several people share a name, so a report starts by asking which records are theirs. The API shows you exactly what a member sees in the app, and you decide; the decision cannot be skipped, because a report that quietly merged two people would be wrong about both.
POST /checks returns every candidate record with:
confidence:ANCHOR(the record at the company you named),HIGH,MEDIUM,LOW, orCONFLICT(a different month and year of birth, so it cannot be the same person).reasons: why, in plain words: a shared company, the same month and year of birth, a shared address, a different birth date.proposed: whether we would tick it by default.controlandtrusteeships: entries on the register of people with significant control, and charity trusteeships, listed under the name, each with the same confidence and reasons and akeyto confirm with.
Narrow a common name with companyNumber, town, or officerLinks (Companies House officer links you already hold, up to ten). With no candidates at all the call answers 200 with an empty list and a message, not an error. A check stays open for 24 hours; confirm within that or start again.
Where every proposed record is ANCHOR or HIGH, you may confirm the proposed set in one go with { "confirm": "proposed" } instead of listing ids. Where it is not, the shortcut is refused and you must choose explicitly. Records with different birth dates cannot be confirmed together, and an id the search did not offer is refused with 400.
Credits
Confirming and paying
Confirming creates the person, sizes their history from the register (the API waits a few seconds for this so it can quote), spends the credits and queues the report. One credit covers the standard reading, which is every history we have measured. A very large history is quoted: quote.standard false, quote.blocks the further blocks of pages, and quote.credits what reading everything would take. Send "readEverything": true to pay that; otherwise the standard reading runs for one credit and the report lists what it did not read.
Too few credits is a 402 that tells you what is needed and what is available; the person is kept, so a later confirm with the same records does not repeat the identity step. The organisation's balance is GET /credits; with ?subjectId= it also quotes a sized person.
HTTP 402
{ "error": { "code": "insufficient_credits", "message": "The organisation has too few credits for this report.",
"creditsNeeded": 1, "creditsAvailable": 0, "subjectId": "sub_01J9…" } }A person your organisation already has a report on is a 409 already_reported, before anything is created or spent: reported names each person already reported on and their latest report, which GET /reports/{id} returns. Two people are the same when they share a confirmed officer record, so a namesake never matches. To run a new, separate report anyway, confirm again with "allowDuplicate": true.
Always send an Idempotency-Key (any string unique to the attempt, a UUID is ideal). A repeat with the same key returns the first response with Idempotent-Replayed: true and spends nothing. A repeat without one is a 409, because the check is already confirmed. If a report cannot be completed, the credit comes back automatically, as it does in the app.
Reports
Status, progress and the model
GET /reports/{id} always returns report with a status of queued, running, review (held for a check by us, rare), ready or failed (with failureReason). Until it is ready the response carries progress: the fourteen steps of the pipeline with their state, how many filings are involved, and for the long steps how far along they are. When ready it carries model: the report itself, every statement with its source, as JSON.
| Field of model | What it holds |
|---|---|
header | Who was checked, which records, when the register was read, your reference. |
summary | The neutral summary and what the report is about. |
companies | One row per company: role, dates, status, filing discipline. |
details | Each company in full: accounts figures by period, filings read, charges, insolvency, Gazette notices. |
recurring | People who held office at two or more of the subject's companies, with each company's fate. |
counterparties | Companies named in related-party notes, looked up on the register. |
flags | Each criterion met, with its severity, the facts it rests on and their citations. |
questions | A neutral question to put to the person for each criterion met. |
sources | Every document read: Companies House document id, filing date, pages. |
officers | A company report: the company's officers and controllers as the register lists them. |
otherRegisters | The UK, US and EU sanctions lists, insolvency registers, HMRC, the Charity Commission, the FCA register, the registers of France and Norway (a person) and GLEIF (a company): what was searched and what was found. |
changes | A later version: what changed since the previous report. |
methodology | How the records were chosen, what was and was not read, the limitations. |
The model carries schemaVersion; a field is only ever added within a version. Reading a report through the API is audited as a view, like opening it in the app. GET /reports/{id}/pdf answers 303 to a signed link valid for five minutes, with the file named by version. A second report on the same person or company is a new, dated version that opens with what changed since the previous one; both stay available.
Companies
Boards and companies
For a board, start from the company. GET /companies/{number}/officers lists its officers as the register does, each with the officer id to pass to POST /checks as an officer link, their roles, whether they are current, their birth month and year, and any significant control they hold.
HTTP 200
{
"company": { "number": "01000001", "name": "EXEMPLAR VENTURES LTD", "status": "active" },
"officers": [
{ "officerId": "1kr721zGSt9RLbqMqfxhSjzjx1E", "name": "Jane Mary Exemplar", "registeredName": "EXEMPLAR, Jane Mary",
"roles": ["director"], "current": true, "currentDirector": true,
"birth": { "month": 3, "year": 1975 }, "control": { "natures": ["ownership-of-shares-75-to-100-percent"], … } },
…
],
"otherControllers": [ … ],
"corporateOfficers": 0
}To check the company itself, POST /companies/{number}/check with { "interest": true, "reference": "…" }. There is no identity step, because a company number is unambiguous; the report is queued at once for one credit and collected the same way.
Webhooks
Hear when a report is ready
Register an https URL once and we POST to it when a report is ready or has failed, and when a watch finds changes. The secret is returned once; keep it with the key.
curl -X POST https://checkthedirector.co.uk/api/v1/webhooks \
-H "Authorization: Bearer ctd_your_key" \
-H "Content-Type: application/json" \
-d '{ "url": "https://cases.example.com/hooks/ctd", "events": ["report.ready", "report.failed", "watch.changed"] }'
HTTP 201
{ "id": "whk_01J9…", "url": "https://cases.example.com/hooks/ctd",
"events": ["report.ready", "report.failed", "watch.changed"], "secret": "whsec_…", "createdAt": "…" }What arrives
POST /hooks/ctd
Content-Type: application/json
User-Agent: CheckTheDirector-Webhooks/1
X-CTD-Event: report.ready
X-CTD-Delivery: dlv_01J9…
X-CTD-Signature: t=1761141820,v1=5f1a…
{ "id": "dlv_01J9…", "event": "report.ready", "createdAt": "2026-10-22T14:17:00.000Z",
"data": { "reportId": "rpt_01J9…", "subjectId": "sub_01J9…", "version": 1,
"reference": "MAT-2026/041", "url": "https://checkthedirector.co.uk/app/reports/rpt_01J9…" } }report.failed carries the same ids and a reason; watch.changed carries watchId, subjectId, checkedAt, the kind and company number of each change, and the page to read them on. A delivery never carries a person's name or the report's content; fetch those with your key.
Watches are started and stopped in the app, on the person's or the company's page; the API has no endpoints for them yet. A watch.changed delivery is sent for every watch your organisation runs, however it was started.
Verify every delivery
v1 is HMAC-SHA256, with the endpoint's secret, over the string "<t>.<raw body>", where t is the Unix time in the header. Compute it over the raw bytes before parsing, compare in constant time, and reject a t more than five minutes from now.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(secret, rawBody, header, now = Date.now()) {
const m = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header ?? "");
if (!m) return false;
const t = Number(m[1]);
if (Math.abs(now / 1000 - t) > 300) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
const given = Buffer.from(m[2], "hex");
return expected.length === given.length && timingSafeEqual(expected, given);
}Answer 2xx within 10 seconds; do the work afterwards. Anything else, or a timeout, is retried 6 times at growing intervals (1, 2, 4, 8, 16, 32 minutes after each failure), so the last attempt comes about 1 hour after the first. Deliveries can arrive more than once and out of order: make your handler idempotent on id. Each attempt, with its status, is listed under Settings → API.
Errors
One shape, nine codes
{ "error": { "code": "rate_limited", "message": "This key has made too many requests this minute." } }| code | HTTP | When |
|---|---|---|
invalid_request | 400 | The body fails validation, or a choice the search did not offer. |
unauthenticated | 401 | No key, or a key that is invalid, expired or revoked. |
insufficient_credits | 402 | Too few credits; the error states creditsNeeded and creditsAvailable. |
forbidden | 403 | The member who created the key has left the organisation. |
address_unconfirmed | 403 | The member who created the key has not confirmed their email address. Nothing was spent. |
not_found | 404 | No such check, report, company or endpoint. Another organisation's is indistinguishable from none. |
conflict | 409 | The check is already confirmed, or has expired. |
already_reported | 409 | The organisation already has a report on this person; the error names it. Nothing was spent. |
rate_limited | 429 | Over the key's limit; Retry-After says how long to wait. |
internal | 500 | Our fault. Retry later; the request is safe to repeat. |
not_configured | 503 | A dependency is not configured on this installation. |
Some errors carry more fields beside code and message, as the 402 above does. A 401 carries WWW-Authenticate; a 429 carries Retry-After in seconds.
Limits
Limits and good practice
- 60 requests a minute and 600 an hour per key. Companies House's own limits are ours to manage, never yours.
- Prefer webhooks to polling. If you poll, every 30 seconds is plenty: the progress tells you which step the report is on.
- A started check is good for 24 hours. Confirm within that, with an
Idempotency-Key, and retry safely. - Store report ids against your matter, not the model: the model is yours to re-read at any time while the report is kept, and a later version is a new id.
- Keep keys and webhook secrets server-side. Rotate by creating a new key, moving over, then revoking the old one.
Reference
Every endpoint
| Method | Path | Purpose |
|---|---|---|
| POST | /checks | Start a check on a person: the candidate records, nothing spent. |
| POST | /checks/{id}/confirm | Confirm the records; sizes, quotes, spends the credits and queues the report. |
| GET | /reports/{id} | Status and progress; when ready, the report model as JSON. |
| GET | /reports/{id}/pdf | A short-lived signed link to the PDF. |
| POST | /companies/{number}/check | Check the Company: one report on a company by number, no identity step. |
| GET | /companies/{number}/officers | A company's officers and controllers as the register lists them. |
| GET | /credits | The organisation's credit balance; with ?subjectId=, a quote. |
| GET | /webhooks | Your endpoints, without their secrets. |
| POST | /webhooks | Register an https endpoint; the secret, once. |
| DELETE | /webhooks/{id} | Remove an endpoint. |
| GET | /openapi.json | The OpenAPI 3.1 document. No key needed. |
Paths are relative to https://checkthedirector.co.uk/api/v1. Request bodies, parameters and error responses are specified in the OpenAPI document; success responses are described there and in the sections above.
Changelog
Versions and terms
- 1.0.0, 22 October 2026: the first version. Checks in two calls, reports as JSON and PDF, Check the Company, a company's officers, credits, signed webhooks.
The terms of business have a section on API access: keys are your organisation's responsibility, and reports and their contents may not be resold.
A question about the API, or something your systems need from it? Ask us.