Skip to content

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.

  1. 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": [ … ] }
    }
  2. 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-Key makes 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"] }
    }
  3. 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 returns model: the whole report as JSON, described under Reports below.

  4. 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, or CONFLICT (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.
  • control and trusteeships: entries on the register of people with significant control, and charity trusteeships, listed under the name, each with the same confidence and reasons and a key to 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 modelWhat it holds
headerWho was checked, which records, when the register was read, your reference.
summaryThe neutral summary and what the report is about.
companiesOne row per company: role, dates, status, filing discipline.
detailsEach company in full: accounts figures by period, filings read, charges, insolvency, Gazette notices.
recurringPeople who held office at two or more of the subject's companies, with each company's fate.
counterpartiesCompanies named in related-party notes, looked up on the register.
flagsEach criterion met, with its severity, the facts it rests on and their citations.
questionsA neutral question to put to the person for each criterion met.
sourcesEvery document read: Companies House document id, filing date, pages.
officersA company report: the company's officers and controllers as the register lists them.
otherRegistersThe 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.
changesA later version: what changed since the previous report.
methodologyHow 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." } }
codeHTTPWhen
invalid_request400The body fails validation, or a choice the search did not offer.
unauthenticated401No key, or a key that is invalid, expired or revoked.
insufficient_credits402Too few credits; the error states creditsNeeded and creditsAvailable.
forbidden403The member who created the key has left the organisation.
address_unconfirmed403The member who created the key has not confirmed their email address. Nothing was spent.
not_found404No such check, report, company or endpoint. Another organisation's is indistinguishable from none.
conflict409The check is already confirmed, or has expired.
already_reported409The organisation already has a report on this person; the error names it. Nothing was spent.
rate_limited429Over the key's limit; Retry-After says how long to wait.
internal500Our fault. Retry later; the request is safe to repeat.
not_configured503A 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

MethodPathPurpose
POST/checksStart a check on a person: the candidate records, nothing spent.
POST/checks/{id}/confirmConfirm 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}/pdfA short-lived signed link to the PDF.
POST/companies/{number}/checkCheck the Company: one report on a company by number, no identity step.
GET/companies/{number}/officersA company's officers and controllers as the register lists them.
GET/creditsThe organisation's credit balance; with ?subjectId=, a quote.
GET/webhooksYour endpoints, without their secrets.
POST/webhooksRegister an https endpoint; the secret, once.
DELETE/webhooks/{id}Remove an endpoint.
GET/openapi.jsonThe 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.