We build OpenRegistry, a live registry API. This post was written with AI assistance.
Every company-data product is either a live query or a copy. Copies are fine until the thing you care about changes after the copy was taken. This post shows how to measure that gap for UK companies yourself, using the free Companies House API, and what we saw when we tried it on four companies.
Why freshness matters
Know-your-business (KYB) checks ask simple questions: does this company exist, is it active, and who runs it? The answers change.
Picture a supplier-onboarding check on a Thursday. The director who signed your contract resigned on Monday, and the company filed the resignation on Wednesday. If your data source last synced on Tuesday, your check says she is still a director, and your file records that you verified her authority to sign. Nothing in the data is invented. It is just two days old.
So the useful question is not whether a source is "right", but how old it is at the moment you look.
How registry changes happen
A UK company tells Companies House about changes by filing forms. These are the ones that matter most for KYB:
| Form | What it records |
|---|---|
| AP01 | Director appointed |
| AP03 | Secretary appointed |
| TM01 | Director's appointment ended (resigned or removed) |
| TM02 | Secretary's appointment ended |
| DS01 | Company applies to be struck off voluntarily |
| GAZ1 / GAZ1(A) | Gazette notice of proposed strike-off (compulsory / voluntary) |
| NEWINC | Company incorporated |
Two details make lag harder to measure than it looks:
-
Every change has two dates. The effective date is when the director actually resigned. The filing date is when Companies House registered the form. Companies have 14 days to report officer changes, so the register itself can trail reality. In the filing-history API these are
action_dateanddate. - Some notices are dated in the future. A GAZ1(A) can show up in filing history dated for its scheduled Gazette publication. Only count filings dated on or before the day you are measuring, or you will get negative lag.
Volume matters too. Replaying the Companies House officers stream, we counted 100,000 officer events in 24.1 hours (7 to 8 September 2026). Most are updates to existing officer records rather than new appointments or resignations, but any copy of the register has to keep up with all of them.
How to measure lag yourself
You need a free API key from the Companies House developer hub. It is sent as the username in HTTP Basic auth, with an empty password.
The method:
- Look up some companies on the source you want to test. Record what it shows and the date you looked.
- Pull each company's filing history from Companies House.
- For each relevant filing registered on or before the day you looked: lag ≥ day you looked − filing date.
This gives a lower bound. You know the copy was still behind when you looked, not when it caught up. Re-check daily if you want the upper bound too.
Python, standard library only:
import base64, json, os, urllib.request
from datetime import date
API = "https://api.company-information.service.gov.uk"
AUTH = "Basic " + base64.b64encode((os.environ["CH_API_KEY"] + ":").encode()).decode()
# Filing types that change what a KYB check would see
WATCH = {
"AP01": "director appointed",
"AP03": "secretary appointed",
"TM01": "director resigned",
"TM02": "secretary resigned",
"DS01": "strike-off application",
"GAZ1": "strike-off notice",
"GAZ1(A)": "strike-off notice",
"NEWINC": "incorporated",
}
def ch(path):
req = urllib.request.Request(API + path, headers={"Authorization": AUTH})
with urllib.request.urlopen(req, timeout=30) as r:
return json.load(r)
def changes(number, since, until):
"""Watched filings registered between `since` and `until` (inclusive)."""
for f in ch(f"/company/{number}/filing-history?items_per_page=25")["items"]:
if f["type"] in WATCH and since <= f["date"] <= until:
yield f["type"], f.get("action_date") or f["date"], f["date"]
# What the third-party source showed, and the day you looked
OBSERVED = {
"SC190660": ("2026-09-10", "status: Active"),
"15150296": ("2026-09-10", "1 officer listed"),
"12782688": ("2026-09-10", "resigned director shown as current"),
"17450921": ("2026-09-10", "company not found"),
}
for number, (seen_on, shown) in OBSERVED.items():
p = ch(f"/company/{number}")
status = p.get("company_status_detail") or p["company_status"]
print(f"{number} register now: {status}")
for ftype, effective, filed in changes(number, since="2026-09-01", until=seen_on):
lag = (date.fromisoformat(seen_on) - date.fromisoformat(filed)).days
print(f" {ftype:<7} {WATCH[ftype]:<22} effective {effective} filed {filed}"
f" | third party on {seen_on}: {shown} -> lag >= {lag}d")
Output:
SC190660 register now: active-proposal-to-strike-off
DS01 strike-off application effective 2026-09-08 filed 2026-09-08 | third party on 2026-09-10: status: Active -> lag >= 2d
15150296 register now: active
AP03 secretary appointed effective 2026-09-09 filed 2026-09-09 | third party on 2026-09-10: 1 officer listed -> lag >= 1d
12782688 register now: active
TM01 director resigned effective 2026-09-07 filed 2026-09-09 | third party on 2026-09-10: resigned director shown as current -> lag >= 1d
17450921 register now: active
NEWINC incorporated effective 2026-09-10 filed 2026-09-10 | third party on 2026-09-10: company not found -> lag >= 0d
Exact timestamps with the streaming API
Filing history gives you dates. For timestamps, the Companies House streaming API pushes each change as it is published, with a published_at time and a timepoint cursor. It uses a separate stream key. You can replay from an earlier timepoint, which is how we measured the daily volume above:
import base64, json, os, urllib.request
from datetime import datetime
STREAM = "https://stream.companieshouse.gov.uk/officers"
AUTH = "Basic " + base64.b64encode((os.environ["CH_STREAM_KEY"] + ":").encode()).decode()
def event_at(timepoint):
"""Replay the stream from a timepoint and return the first event."""
req = urllib.request.Request(f"{STREAM}?timepoint={timepoint}", headers={"Authorization": AUTH})
with urllib.request.urlopen(req, timeout=60) as r:
for line in r:
if line.strip(): # blank lines are heartbeats
return json.loads(line)["event"]
a, b = event_at(61_100_000), event_at(61_200_000)
t = lambda e: datetime.fromisoformat(e["published_at"])
hours = (t(b) - t(a)).total_seconds() / 3600
print(f"{a['published_at']} -> {b['published_at']}")
print(f"{b['timepoint'] - a['timepoint']:,} officer events in {hours:.1f} hours")
Output:
2026-09-07T17:35:03 -> 2026-09-08T17:44:02
100,000 officer events in 24.1 hours
What we found
On the morning of 10 September 2026 we looked up four companies with recent changes on OpenCorporates, a widely used company-data aggregator, and compared them with Companies House.
| Company number | Change on the register | Effective | Filed | Third party on 10 Sep | Lag (at least) |
|---|---|---|---|---|---|
| SC190660 | Voluntary strike-off application (DS01). Register status: active, proposal to strike off | 8 Sep | 8 Sep | Active, no strike-off flag | 2 days |
| 15150296 | Secretary appointed (AP03): Teresa D*** S****** | 9 Sep | 9 Sep | 1 officer; secretary not listed | 1 day |
| 12782688 | Director resigned (TM01): Esther R*** F***** | 7 Sep | 9 Sep | Still listed as a current director | 1 day (3 from the resignation) |
| 17450921 | Incorporated (NEWINC) | 10 Sep | 10 Sep | Company not found | Same day |
What stands out:
- All four lags are short. One to two days from filing is what you would expect from a pipeline that refreshes about daily. That is the normal trade-off of any copy, not a scandal.
- "Updated today" is not "includes today's filings". The page for 15150296 said it last updated from source on 9 September, the same day the AP03 was filed. The likeliest explanation is timing: the sync ran before that filing was published.
- 12782688 shows why the two dates matter. The resignation took effect on 7 September but only reached the register on 9 September. Against reality, the third-party record was three days behind, and two of those days happened before the aggregator could have known.
- 17450921 was incorporated that morning. Almost any copy misses a same-day incorporation. It is only useful as a test of how quickly a source picks up new companies.
Limits
- A tiny, non-random sample. Four companies, all chosen because they had just changed, which is the worst case for any copy. This says nothing about the average freshness of any source.
- One country, one day. OpenCorporates covers far more countries than the UK, and far more than we do. Freshness elsewhere may be better or worse.
- Lag is not the same as wrong data. Each third-party record was presumably accurate when it was copied. The risk is treating a copy as if it were live.
- Lower bounds only. We looked once and did not measure when each record caught up.
Ways to get live data
If the lag matters for your use case, ask the source at the moment you need the answer:
- The Companies House REST API. Free, and the code above is most of what you need. The standard rate limit is 600 requests per five minutes.
- The Companies House streaming API. Push-based: subscribe to officers, company profiles, filing history and more, and update your own records as changes are published. The right choice if you keep a local copy and need it current.
- A live multi-registry API. If you need the same live lookups across several countries' registries through one interface, that is what we build at OpenRegistry. It queries the official registries at request time instead of serving a copy.
Top comments (0)