Provide current and historical ownership information on domains / IPs. Identify all connections between domains, registrants, registrars, and DNS servers.
Look into all current and historical DNS / IP connections between domains and A, MX, NS, and other records. Monitor suspicious changes to DNS records.
Get detailed context on an IP address, including its user’s geolocation, time zone, connected domains, connection type, IP range, ASN, and other network ownership details.
Access our web-based solution to dig into and monitor all domain events of interest.
Get access to a web-based enterprise-grade solution to search and monitor domain registrations and ownership details for branded terms, fuzzy matches, registrants of interest, and more.
Independent, evidence-based DNS and abuse intelligence for applicants, advisors, governments, and counsel participating in the ICANN 2026 New gTLD Program.
Predictive threat intelligence is your best first line of defense. Subscribe to the feeds to strengthen your cybersecurity posture. Contact us today for more information.
Unlock integrated intelligence on Internet properties and their ownership, infrastructure, and other attributes.
Our complete set of domain, IP, and DNS intelligence available via API calls as an annual subscription with predictable pricing.
Offers complete access to WHOIS, IP, DNS, and subdomain data for product enrichment, threat hunting and more.
Uncover entire attack surfaces with this API to embed asset discovery, vulnerability scanning, and technology metadata into your platform. Now in early access.
Talk to our APIs using LLMs. Connect your preferred LLM to WhoisXML API and simply chat about WHOIS, DNS, threat intelligence, and more.
I’m your Domain Intelligence Assistant. I make it easy to explore WHOIS, DNS, and threat data from WhoisXML API — I’m cloud-based, fast, and always ready to help.
A custom GPT for WHOIS, DNS, IP, and threat intelligence research. Connects ChatGPT directly to WhoisXML API to enable fast, conversational investigations and domain insights.
One Universal API Credit Pool
Simplify API credit purchasing and usage with one balance that works across our product portfolio.
Part 2 of 2. Sections 6 to 14 are on this page. Sections 1 to 5 are on Data dictionary, part 1.
openrtb_cat — an array columnopenrtb_cat holds the OpenRTB / AdCOM raw category ids for the row's tier path,
ordered root → leaf. The taxonomy those ids belong to is published once per export as
cattax in manifest.json (value 9 = IAB Content Taxonomy 3.1).
This column is array-valued, and its physical representation differs by file format:
| Format | Representation |
|---|---|
.parquet |
native list<string> |
.csv.gz |
a JSON-encoded array in a single cell, e.g. ["596", "627", "52"] |
Integration warning — this will surprise your loader. No comparable categorization feed on the market ships an array-valued column, so most CSV pipelines are not built to expect one. In the CSV the cell contains a JSON document that must be parsed, not split on a delimiter, and the standard CSV quoting rules apply to the embedded quotes. Budget for this explicitly.
openrtb_cat is NULL — not an empty array — whenever the tier path does not
resolve to ids. An empty array would read as "resolved to zero categories", which is a
different statement.
| Format | How NULL appears |
|---|---|
.csv.gz |
an empty field (nothing between the delimiters) |
.parquet |
a true null |
Absence is always null, never a sentinel. No column uses a placeholder to stand in
for a missing value: you will not find "N/A", "Unknown", -1, or an empty string
meaning "we don't know". If a fact is absent, the field is null.
language (column 14) is the one column whose value comes from the page itself: the crawled
page's lang attribute, passed through unchanged. A page that declares lang="null"
therefore ships the four-character string null. That is
the page's word, not our placeholder — treat any such token as "no usable language". The
1.14.0 language-NULL guard (§10) keys on a genuinely absent value and
leaves such rows alone.
One value looks like a sentinel and is not. confidence_tier can carry the
non-ordinal token PARKED (see §5). That is a positive
statement from the classifier, not a stand-in for a missing confidence — where
confidence is genuinely absent, confidence_tier is null like everything else.
Columns 45–46 are the plainest case of null meaning absent. content_purpose_id is null on
every row outside a curated list of known news publishers — which is nearly every row, news
sites included — so a null there is unknown, never "not news"
(§14).
Other encoding notes:
domain is lowercase ASCII, with
internationalised names in punycode (xn--): a source row whose name arrives in Unicode
form is withheld by the exporter rather than shipped beside its punycode twin, and the
withheld count is published in the manifest as row_count_excluded_non_ascii_domain.
Artifacts before 1.14.0 still carry those rows, most of them beside the same name in
punycode, so one registrable domain appears twice. To apply
the 1.14.0 rule to an earlier artifact, drop every row whose domain is not ASCII.classified_at reads 2026-09-09T18:46:49.941343+00:00 on model-written rows and
2026-05-08T00:00:00+00:00 on curated rows, while status_observed_at carries the crawl's
own precision on every lane. Parse every timestamp column with a format that accepts an
optional fraction.
They are transported as strings in both formats — including in Parquet — for
portability across loaders.confidence and calibrated_confidence are written with 4 decimal places in CSV
so they match the Parquet float32 value exactly.last_known_* columnsColumns 18–23 carry a historical content label: one this domain held at an earlier point, which is no longer presented as current.
They are populated only on parked, inactive and dead rows — never on
active (where the label is current, in columns 2–6) and never on blocked.
This is a sparse column block, and how sparse it is moves between exports: on the large majority of non-live rows it is empty, and the non-live population per partition grows over time, so a figure measured on one export is out of date on the next.
Read coverage off the delivery you actually received. Every export publishes its own
row_count_with_last_known_category and row_count_by_domain_status in manifest.json.
That pair is exact for the artifact in your hands and needs no scaling; a figure quoted in
documentation never will be, which is why none is quoted here.
The reason coverage is low at all is structural, not a gap we intend to close: the great majority of non-live domains were already parked, inactive or dead the first time we crawled them, so there was never a live label to retain. A historical label exists only where we observed the domain live, labelled it, and later observed it non-live.
Do not treat this as a parked-wide feature. If your integration depends on knowing what a parked domain used to be, it will be unpopulated on the overwhelming majority of rows — verify against your delivery's own count before building on it.
Scope of a partition measurement, stated precisely. Both feed tables are
HASH(domain) on the same 16-way modulus, so domains are distributed uniformly across
partitions, but the lane mix is not: in the 2026-10-18 set the blocked and dead shares and
the last_known_* count differed noticeably from one partition to the next. Scaling one
partition by sixteen gives an estimate, and only for the export it was taken from. The
count grows over time as more domains are categorized while live and later observed
non-live, so a scaled figure ages the moment the next snapshot is built. Sum the sixteen
partition manifests of the delivery in front of you (or read the run-level
run_manifest.json, §10) when the exact feed-wide number matters.
Where a last_known_* value is present it is dated by last_known_classified_at
and is a statement about the past. It is never a claim about the domain's current
content or its current owner.
On parked, inactive and dead rows the content columns are NULL deliberately.
A parked page, a coming-soon placeholder and a dead domain are not "sites about
something" — running a content classifier over a parking lander would produce a label
describing the parking service, not the domain. Our position is that the honest
classification of a parked domain is domain_status = parked, dated by
status_observed_at. Categorization vendors generally take the same approach.
Two consequences to encode in your integration:
active rows only. Every export states this
explicitly — see below.manifest.jsonEvery export directory contains a manifest. The keys most relevant to a consumer:
| Key | Meaning |
|---|---|
feed_schema_version |
Semantic version of the artifact (columns + manifest keys). Pin against this. |
schema_version |
The frozen column-contract identifier, feed_v1. |
columns |
The column list, in contract order. |
row_count |
Total rows — blended across all six lanes. |
row_count_by_domain_status |
Rows per lane. Sums to row_count. |
content_accuracy_denominator_lane |
Which lane is answerable. Always active. |
content_accuracy_denominator_row_count |
The answerable row count — the correct denominator for any accuracy figure. |
row_count_with_last_known_category |
Rows carrying a historical label (§8). |
confidence_calibration_map_version |
Which fitted map produced the calibrated_* columns (artifact ≥ 1.6.0). A refit changes those values with no raw column changing, so pin this key if you need to know two snapshots' calibrated values are comparable. See §5. |
confidence_band_policy |
Which band policy produced column 8 (artifact ≥ 1.14.0). v1 is the raw pass-through — the stored confidence cut, verbatim — and a manifest that does not carry this key is v1. v2 is the derived band described in §5, v3 (artifact ≥ 1.15.0) is v2 plus rule 5, which caps the name-only lane at MEDIUM, and v4 (artifact ≥ 1.16.0) is v3 plus rule 4, which caps at MEDIUM — and at LOW where calibrated_confidence_tier is DOUBTFUL, a stored MEDIUM included — any ensemble row whose model_version provenance suffix (split_part(model_version, '+', 2)) is teacher_bf1, nocontent_bf1 or teacher_purpose_v2. The band is derived rather than tracked, so a delta whose policy differs from the artifact it extends is reported as a chain gap, and re-publishing an existing artifact key under a different policy is refused — that refusal is ours, at publish time, and is a different mechanism from the gap. A chain gap is a flag, not a refusal: whether a policy step actually re-rates any row you already hold depends on the rule, so the changelog entry for that policy is what tells you whether you need a new full snapshot or the deltas carry it. For v2 → v3 the deltas are enough — rule 5 is keyed on the llm-name/* writer alone, so no row written before that lane's first load re-bands. For v3 → v4 they are not: rule 4 is keyed on model_version, so rows published in every set since 1.14.0 re-band, and the change reaches you only by ingesting the full Sunday 2026-10-11 set — see that release's changelog entry. The check compares each artifact with its immediate predecessor in the chain, not with the last snapshot, so the first delta after the 1.15.0 deploy reports the policy change and the deltas after it — which extend a v3 artifact — are quiet. That same first delta also crosses feed_schema_version 1.14.0 → 1.15.0 and reports that step separately: expect two reports on that one delta, then silence. Both are expected, and the silence that follows is not a sign the check stopped running. The first delta after the 1.16.0 deploy, delta_2026-10-09_100000Z, does the same: it reports v3 → v4 and 1.15.0 → 1.16.0, and the deltas after it report neither step. Pin this key beside confidence_calibration_map_version — under v2 a refit of the calibration map moves band membership as well as calibrated values. |
row_count_calibrated |
Rows carrying a calibrated_confidence (§5). Lets you see the calibrated share of an export without scanning it. |
row_count_curated |
Rows from the hand-vetted curated list (artifact ≥ 1.7.0) — model_name beginning curated/. See §5. |
status_only_lane |
Whether this artifact carries the status-only lane (§9). On a snapshot: the whole lane — every inventory domain the lane can ship that has no category row (the parked, placeholder and never-categorized population), as distinct from category-lane rows a guard held to status-only (those are counted by the suppression keys below); never-shippable dead inventory (inventory_never_shippable_by_rank_bucket) produces no row. Its size is not published as one key: row_count_by_domain_status gives the parked / inactive / dead counts that make up nearly all of it — read them off your own delivery rather than any fixed feed-wide figure. On a delta (artifact ≥ 1.9.0): the lane's rows whose inventory moved since the cutoff — the baseline still arrives only with a snapshot. Part of the artifact's identity: a re-publish at the same key with this toggled is refused rather than silently changing the artifact. |
curated_lane |
Whether this artifact carries curated rows at all (artifact ≥ 1.7.0). true on snapshots, and on deltas from artifact ≥ 1.13.2; false on a delta below 1.13.2, which carried the curated exclusion but not the rows. row_count_curated is how many actually shipped. |
rank_ladder |
present or absent (artifact ≥ 1.7.0) — whether popularity-rank buckets were available when this export ran. absent means the bucket keys below are not in this manifest; it is a stated value so their absence is never ambiguous. Mostly operational telemetry, but two row-level rules read the buckets: the language-NULL head exemption (language_null_suppression) and the empty-capture blocked lane (§3, parked_stub_suppression). With rank_ladder absent, neither applies. |
row_count_by_rank_bucket |
Present only when rank_ladder is present: exported rows per popularity bucket, keyed "0" top-100k and "1" top-1M on the crawler's rank ladder, and "3" the rest; "2" is reserved and not emitted. The ladder takes a domain's best rank across the lists it ranks on, so bucket "0" holds more than 100,000 rows. Counts rows backed by a crawl-inventory record, so it is drawn from the same population as the next key and does not necessarily sum to row_count — use row_count_by_domain_status for a breakdown that does. |
inventory_total_by_rank_bucket |
Optional: the size of our crawl inventory in each bucket, when the (expensive) inventory scan was enabled for the run. |
coverage_by_rank_bucket |
Present only when both of the two keys above are: the ratio of the first to the second, per bucket, to 4 decimals. It counts presence in the feed, labelled or not, so it is not a labelled-coverage figure. |
row_count_dead_status_by_rank_bucket |
(artifact ≥ 1.13.0) Present only when rank_ladder is present: of the rows in row_count_by_rank_bucket, those whose crawl status is in our dead set — in practice not_found (404) and, from 2026-09-23, a definitive DNS failure (NXDOMAIN or no address; changelog 2026-09-28) — they ship because a category or curated row exists for them. |
inventory_dead_status_by_rank_bucket |
(artifact ≥ 1.13.0) Optional, from the same inventory scan as inventory_total_by_rank_bucket: inventory keys per bucket whose crawl status is in the dead set (uncrawled keys, which have no status yet, are NOT dead). Absent when the scan could not read the flag on every row. |
inventory_never_shippable_by_rank_bucket |
(artifact ≥ 1.13.0) Present only when both keys above are: dead-status inventory minus the dead-status rows that shipped, floored at 0 — keys that cannot produce a row until a re-crawl succeeds (our heal lane retries not_found on a 180-day backoff, and a definitive DNS failure on a 14-day one). Slightly overstated when a key heals between the inventory scan and the row stream of the same export. |
coverage_shippable_by_rank_bucket |
(artifact ≥ 1.13.0) Present only when the key above is: row_count_by_rank_bucket over (inventory_total_by_rank_bucket minus inventory_never_shippable_by_rank_bucket), to 4 decimals, capped at 1.0 (the heal skew above can push a bucket over). A bucket whose keys are all never-shippable is omitted, not published as 0. The existing coverage_by_rank_bucket is unchanged; this is the stricter-denominator companion. |
status_age_reference_date |
(artifact ≥ 1.14.0) The date every row's status_observed_at was aged against for the three keys below: a snapshot's own date, a delta's cutoff day (UTC). It is the artifact's key, never the time the export ran, so a re-published artifact reports the same ages. null only on an export produced outside the scheduled lanes, in which case the three keys below are absent rather than clock-dated. |
row_count_by_status_age |
(artifact ≥ 1.14.0) Rows per lane per status age, where age is status_age_reference_date minus the UTC calendar day of status_observed_at: le_7d (≤ 7 days), le_30d (8–30), le_90d (31–90), gt_90d (> 90) and null (no status_observed_at). Each row lands in exactly one bucket, so every lane's five buckets sum to its row_count_by_domain_status entry and the whole map sums to row_count. The crawler revisits domains in cohorts, so a bucket can move sharply between two consecutive days' references. |
row_count_by_status_age_by_rank_bucket |
(artifact ≥ 1.14.0) Present only when rank_ladder is present: the same five-bucket split per popularity bucket, over the inventory-backed rows row_count_by_rank_bucket counts — so each bucket's five ages sum to that key's entry, and the map does not necessarily sum to row_count. |
stale_fraction_active_gt_90d |
(artifact ≥ 1.14.0) The active lane's gt_90d count over the lane's whole row count (content_accuracy_denominator_row_count), to 4 decimals; null when the export has no active rows. A one-number freshness read for the answerable lane — compare it across your own deliveries rather than against a fixed threshold, for the cohort reason given under row_count_by_status_age. On a delta the active lane is the changed-row set plus the curated registry (carried whole on every delivery), not the corpus — a row whose crawl moved inside the window ages to zero or below against the cutoff day, while re-classified rows and curated rows carry arbitrary crawl ages — so a delta's value is structurally fresh-skewed. Compare snapshots with snapshots and deltas with deltas, never one against the other. |
policy_pack |
Which policy pack produced the policy_* columns (artifact ≥ 1.12.0): {"id": "policy_v1", "version": "2026.09.2", "sha256": "…"} on every 1.12.0–1.13.x artifact and {"id": "policy_v2", "version": "2026.09.1", "sha256": "…"} from 1.14.0. version is the mapping version — a pack edit changes published groups with no column change, so pin it to know two snapshots' groups are comparable; sha256 is the pack file's digest. version and sha256 null means the pack was unavailable, so the four pack-derived columns — policy_group, policy_basis, policy_pack_version and policy_group_set — are NULL on every row because nothing could project them, not because nothing resolved. policy_confidence_basis (column 42) is independent of pack availability — it is derived from model_name and the calibrated value — so it stays populated beside a null pack, as does calibrated_confidence; do not read a populated basis as evidence the pack loaded. See §11. |
row_count_with_policy_group |
Rows carrying a policy_group (artifact ≥ 1.12.0). Lets you see the projected share of an export without scanning it; 0 beside a null policy_pack.version is the degraded case. |
site_purpose_model |
Which model populated the reserved site_purpose_* columns (artifact ≥ 1.12.0): null until a purpose pass runs, then {"name": …, "version": …}. |
row_count_by_site_purpose_status |
Rows per site_purpose_status value — labeled / undefined / not_available (artifact ≥ 1.12.0). labeled: 0 is the honest statement that the axis is reserved, not populated. |
manifest_keys_version |
(artifact ≥ 1.14.0) The version of the manifest's KEY SET, independent of feed_schema_version. Pin this if what you parse is the manifest rather than the columns: a MINOR bump adds a key, a MAJOR one removes or re-means one. An artifact carrying no such key predates 1.14.0. 1.0.0 from 1.14.0; 1.1.0 from 1.17.0, which added content_purpose_list and row_count_by_content_purpose_source. |
row_count_by_confidence_tier |
(artifact ≥ 1.14.0) Rows per confidence_tier — HIGH / MEDIUM / LOW / PARKED / null. All five keys are always present, so a 0 is a measured zero. null counts rows carrying NO band and nothing else: should a row ever ship a value outside the four documented bands, it is counted under that value's own key rather than folded into null, so the map still sums to row_count and the anomaly is visible rather than disguised as an absence. Sums to row_count. |
row_count_by_writer_family_x_tier |
(artifact ≥ 1.14.0) The same distribution split by which family wrote the label: ensemble / filter / vendor_rule / rule / curated / carry_over / name_only (artifact ≥ 1.15.0) / other / unknown. confidence is not one scale across those families (§5), so a blended band distribution hides whether HIGH is classifier rows or filter rows. One family per row, so the inner maps sum to row_count. Every inner map carries the same key set as row_count_by_confidence_tier, including any out-of-vocabulary key described there, so the two can be diffed cell by cell. The curated family here is the WRITER family, not the curated lane: it also counts human-feedback rows (a reviewer's accepted verdict), where row_count_curated above counts only rows whose model_name begins curated/ — so the two keys need not agree, and curated here is the larger of the two whenever any reviewer verdict shipped. |
row_count_by_policy_group |
(artifact ≥ 1.14.0) Rows per policy_group, with every group the pack declares present, 0 where this artifact has none — so "no gambling-shaped rows here" and "the pack stopped mapping gambling" are distinguishable. {} means the pack did not load. Sums to row_count_with_policy_group. |
row_count_policy_group_uncalibrated |
(artifact ≥ 1.14.0) Rows that resolved a policy_group but carry no calibrated_confidence — the population a group filter would be thresholding on a raw, scale-incomparable number. Bounded above by row_count_with_policy_group. |
row_count_by_policy_group_by_writer_family |
(artifact ≥ 1.14.0) Which writer families populate each group. A group whose rows are all filter should be read as a filter verdict, not as a classifier's opinion. Each inner map sums to that group's row_count_by_policy_group entry. |
row_count_by_safety_status |
(artifact ≥ 1.14.0) Rows per safety_status — exactly flagged / assessed_clean / not_assessed. Sums to row_count. |
row_count_by_safety_flag |
(artifact ≥ 1.14.0) Flag occurrences per theme. A row carries zero, one or several flags, so this is the one key in this block that does not sum to row_count. |
row_count_by_language_primary |
(artifact ≥ 1.14.0) Rows per language_primary: the 30 largest subtags by name, everything else summed under other, and rows with no observed language under null. other and null are not language codes, and a row whose language_primary IS the literal string null (a page whose lang attribute said so) counts under other, never under null — that bucket means no language was observed. null is held OUT of the top-30 ranking, so a partition rich in languages can never rank the no-language bucket into other. Sums to row_count; at run level the map is re-capped after summing — the re-cap ADDS its tail to the other the partitions already carried, so the total stays exact — while a subtag that fell into other on some partitions is under-attributed by name. Because the partitions' summed other takes part in that re-cap's ranking, the run-level map usually names 29 subtags, not 30. |
content_purpose_list |
(artifact ≥ 1.17.0) Which news-publisher list set columns 45–46: {"path": …, "sha256": …, "row_count": …}. sha256 is the list file's digest and row_count the number of domains it names. Pin sha256 to know two artifacts were tagged from the same list: an edit to the list changes the tags with no column change. On a delta the curated count in row_count_by_content_purpose_source equals this block's row_count — not the manifest's top-level row_count — unless a listed domain did not ship as a curated row (a takedown, for example); a partition manifest counts only its own partition's tagged rows. The snapshot run marker carries the set's list as {sha256, row_count} and is not written for a set tagged from two lists. See §14. |
row_count_by_content_purpose_source |
(artifact ≥ 1.17.0) Rows per content_purpose_source — curated / schema_org / null. All three keys are always present, so a 0 is a measured zero: schema_org is reserved and reads 0 on every 1.17.0 artifact, and null counts every untagged row. Sums to row_count; the run marker sums it across the sixteen partitions. |
row_count_tier1_changed |
(artifact ≥ 1.14.0, deltas and the snapshot run marker) Qualifying tier-1 transition events inside the window, from our category-transition ledger — first labels excluded. On a delta the window is that delta's own; on a snapshot's run_manifest.json it is the ledger window between the previous snapshot's run marker and this one, read with ONE query over the whole interval rather than summed over the deltas inside it — so an overlap or a gap between two consecutive delta windows does not move this number. Counted per event, not per domain: a domain relabelled A → B → C inside one window contributes twice, so the key is not de-duplicated by domain and is not a fraction of row_count. A planned re-score announces itself here: a delta's row_count says how many rows moved, not whether they moved between categories. On a run marker a 0 beside a non-null tier1_transitions_reason is that reason, not a measured zero — see the reason key below. |
top_tier1_transitions |
(artifact ≥ 1.14.0, deltas and the snapshot run marker) The twenty largest {from, to, count} tier-1 moves in the same window as row_count_tier1_changed, largest first. Each count counts events on the same terms as row_count_tier1_changed, so one domain can appear in two pairs. The tail is not published, so these do not sum to row_count_tier1_changed. |
policy_group_transitions |
(artifact ≥ 1.14.0, snapshot run marker only) The moves behind top_tier1_transitions, each projected through the CURRENT policy pack at the full tier path it was recorded at: {from, to, count} over policy_groups, largest first, capped at twenty. Full paths from the 2026-11-08 set; before it, the projection read each move's tier-1 label alone, so a move under a deeper pack rule was counted in its bare tier-1 label's group — Attractions > Casinos & Gambling as travel, for example (Website Categorization feed changelog). A move whose two paths land in the SAME group is dropped deliberately — a move a group filter cannot see is not churn a blocker needs to act on — so these do not sum to row_count_tier1_changed either, and the moves behind one tier-1 pair can split across several group pairs. A move that keeps its tier-1 label is not counted here at all, even when a deeper tier changes its group. Projected over the moves behind the twenty published pairs, so the tier-1 tail those drop is dropped here too. [] when the pack did not load, and also when the verified 16-partition set was projected with a pack this build is not running — the groups would otherwise come from a different mapping than the marker's own policy_pack key names, which a re-run recovering a marker after a pack change could do. Deltas do not carry this key, and a delta's top_tier1_transitions cannot rebuild it: a tier-1 pair does not say which path each move took. |
tier1_transitions_window |
(artifact ≥ 1.14.0, deltas and the snapshot run marker) {since, until} for the churn keys above, or null when no churn was measured (a run marker also says why, in tier1_transitions_reason). null is not a zero. Half-open on the ledger's own occurred_at: a row exactly at since is counted, a row exactly at until is not. On a DELTA, until is read when the export starts while the NEXT delta's since is the cursor captured when this one's run began (its 10:00 UTC start, to the minute), so two consecutive delta windows OVERLAP by the time between the two and a ledger row landing in that gap is counted by both. Normally that is the export's start-up latency: at the observed churn rate, a row or two per delta. When a weekly set is still running at 10:00 UTC on a day other than Sunday, the delta waits for it to finish instead of skipping the day, and the overlap is that whole wait — its events are counted by both deltas, while the run marker's count is unaffected. The row stream overlaps by the same convention on purpose, being an idempotent upsert. On a RUN MARKER, until is the marker's own created_at and since is the previous snapshot marker's, so two consecutive snapshot windows tile the interval exactly — no gap, no double count — and re-publishing a date RETAINS BOTH of the first marker's bounds — its created_at and the since it published — so a recovery re-run can neither move a boundary the next snapshot already abuts nor narrow the interval it told you these counts cover, even if a marker for a date inside that interval appears in between: both bounds, and the counts over them, are reproducible. The window rides the marker rather than being inferred from the weekly cadence. |
tier1_transitions_since |
(artifact ≥ 1.14.0, snapshot run marker only) The lower bound of the window above, published as a bound in its own right: the previous snapshot marker's created_at, or null when this run located no previous marker to measure from. On a measured marker it is exactly tier1_transitions_window.since. On a marker that published a tier1_transitions_reason instead of a window it is the interval that run would have measured over — for every one of those reasons, including a re-run made with no ledger configured at all — which is what makes the bound survive a re-run that could not measure, so the recovery re-run after it reuses the interval you were told these counts cover rather than re-deriving one. It is not itself a claim that anything was measured: read the counts off tier1_transitions_window and tier1_transitions_reason as before. |
tier1_transitions_reason |
(artifact ≥ 1.14.0, snapshot run marker only) null when the window above was measured; otherwise why it was not — and the value tells you how to read the counts. predecessor marker missing / no 1.14.0 predecessor: there is no earlier ≥ 1.14.0 snapshot to measure from (the first snapshot of the chain), and the three churn keys are published as 0 / [] / [] — the absence of a predecessor, not a measurement of no churn. ledger not configured / ledger engine could not be opened / ledger read failed / predecessor marker unreadable / predecessor marker is not older than this run / a later snapshot's window is already published / later snapshot marker unreadable / successor scan exhausted before this run's upper bound: the measurement could not be made, and the three churn keys are absent rather than reported as zero. The last three are the out-of-order case: a snapshot whose date is recovered below one already published would have to measure over an interval that later snapshot already covered, so it withholds rather than announce the same churn twice — and it withholds the same way when the check for such a snapshot ran out of budget before reaching the day this run's own window closes on. Its tier1_transitions_since is null too, and the later snapshot's own bounds and counts are untouched. Either way the marker is written and the completeness contract below is unchanged. |
partition |
(snapshots) Which of the sixteen partition folders this manifest describes, p00 … p15 — the same token as the folder suffix. Absent on deltas. |
parquet_codec, parquet_row_group_size |
How the Parquet twin was written: the compression codec (zstd) and the row-group size (1,000,000 rows). Loader hints only; the CSV has no equivalent. |
openrtb_taxonomy_loaded |
true when the AdCOM mapping behind openrtb_cat loaded for this export. false means the openrtb_cat cells of this artifact are empty for a tooling reason, not because no mapping exists — treat the column as unavailable on that artifact. |
unmapped_crawl_status_counts |
Crawl statuses the lane mapping did not recognise, with their row counts; {} when none. Non-empty is the fail-loud signal behind the uncategorizable lane in §3: those rows went there rather than being guessed. |
row_count_excluded_non_ascii_domain |
(artifact ≥ 1.14.0) Rows the exporter withheld because the source domain was not ASCII — a Unicode-form internationalised name, which would otherwise ship beside its punycode twin as a second row for one registrable domain. Always present; 0 is a measured zero; summed in the run marker. The count includes names whose punycode form is not in the feed yet; those return once the inventory ingest normalises them. |
cattax |
AdCOM taxonomy id for openrtb_cat ids (9 = IAB Content Taxonomy 3.1). |
status_lane_notice |
Machine-readable restatement of the lane semantics in §3. |
parked_stub_suppression |
The empty-capture override (artifact ≥ 1.5.0; named for the parking-provider stub that dominated it then): content_hash names the fingerprint of a capture that extracted to no readable text; row_count_overridden counts the rows whose category this export withheld for it. A row whose crawl succeeded ships parked, or blocked where that capture is from 2026-07-31 and the domain is in popularity bucket 0 or 1, when the export carries the buckets (a row whose only label is a name-only one keeps the lane its crawl decides, §3); one whose latest crawl reports a fresher non-content status (dead, blocked, …) keeps that lane. All of them carry no content category and no last_known_* block. It does not trend to zero: an empty capture of a live site produces the same fingerprint, so the count holds roughly steady from week to week (see the known issue in §3). |
no_evidence_suppression |
The no-evidence guard (artifact ≥ 1.8.0): ensemble-family rows (model_name_prefixes) whose category was produced with no captured content (content_signal: content_hash is NULL — no crawl ever captured page content — with language NULL/empty as a guard conjunct) ship status-only — category columns NULL, no last_known_* block, domain_status unchanged. row_count_overridden counts the rows this export suppressed. Rows whose label had captured content behind it, and rule-derived rows (tld_rule/*, schema_rule/*, curated/*, v3_historical_fallback), are never affected. Consume the first ≥ 1.8.0 set as a full per-partition replace — the same replace model removals already require; a delta stream cannot retro-apply the mask to rows that don't otherwise change. Trends to 0 as re-crawls land. |
boilerplate_template_suppression |
The boilerplate-template guard (artifact ≥ 1.10.0): ensemble-family rows (model_name_prefixes) whose content_hash is on the published content_hashes list — 60 vetted templates at introduction: hosting-provider parking/coming-soon pages, registrar reserved/for-sale pages, stock web-server and panel defaults, expiry pages, and similar — ship status-only: category columns NULL, no last_known_* block, domain_status unchanged. The crawl captured the HOST's boilerplate rather than owner content, so the label described the hosting provider, not the domain (most visibly as spurious Web Hosting leaf labels). The full hash list is published in the block so the artifact is self-describing about which rows the guard can touch; row_count_overridden counts the rows this export suppressed. Rule-derived rows are never affected. Same transition rule as 1.8.0: consume the first ≥ 1.10.0 set as a full per-partition replace. Trends to 0 as re-crawls land — new content changes the hash and releases the row. |
demoted_label_suppression |
The demoted-label guard (artifact ≥ 1.11.0): ensemble-family rows (model_name_prefixes) — and, from 1.13.1, rows whose model_name is exactly one of filter_model_names (pipeline/st_filter, pipeline/gambling_filter, pipeline/adult_filter, pipeline/vendor_rule; matched whole, never as prefixes) — whose label the WRITE side branded parked-demoted (signal: the category row's is_parked flag is TRUE — set since 2026-08-22 by the classify-time placeholder detector, or by the crawl's own parked verdict carried on the record being classified) ship status-only: category columns NULL, no last_known_* block, domain_status unchanged. This closes the gap the vetted-hash guard above cannot: the detector generalizes across template variants while the hash list is exact-match, so a demoted row whose template is not (yet) on the vetted list previously shipped its fabricated category. Rows the hash guards already suppress are counted under those blocks, never here — the four row_count_overridden counts are disjoint (five from 1.14.0, with language_null_suppression below). Curated rows are never affected, and neither is any rule-derived row outside filter_model_names (tld_rule/*, schema_rule/*, v3_historical_fallback). Same transition rule as 1.8.0/1.10.0: consume the first ≥ 1.11.0 set — and the first ≥ 1.13.1 set — as a full per-partition replace. Trends to 0 as re-crawls under the fixed detector replace the demoted labels' ground truth. From 1.13.1 the block also publishes the block-critical hold. block_critical_held counts filter rows deliberately NOT suppressed because their policy_group is block-critical — counted there and never in row_count_overridden; on the category-keeping lanes (active, blocked, uncategorizable) such a row keeps its category and PARKED tier, and on the parked/dead/inactive lanes, where the lane already blanks the current category, it keeps its last_known_* history. Beside it: block_critical_groups (the groups actually held in that run), block_critical_group_policy (the eight-group rule — adult, drugs, gambling, gambling_games, harmful_content, hate, piracy, weapons), unresolved_group_held (rows held fail-closed because their group could not be resolved — non-zero beside a policy_pack block whose version and sha256 are null means the pack did not load and the hold ran blind for that export; the block itself is always present) and policy_group_delta (per-group before/after counts, over rows on category-keeping lanes only). |
language_null_suppression |
The language-NULL guard (artifact ≥ 1.14.0): ensemble-family rows (model_name_prefixes) whose label carries language NULL/empty behind a captured content hash (content_signal: content_hash NOT NULL, crawl_status NOT NULL, language NULL/empty — the population the no-evidence guard above deliberately spares, so the two counters are disjoint by construction) ship status-only: category columns NULL, no last_known_* block, domain_status unchanged. Suppressed on a judged basis, not a re-score — judged_basis restates it: a blind judge upheld very few of the re-scored labels in the class, against a control of same-lane labels the re-score had left unchanged, and a large share of the sample was dead or contentless at fetch. The candidate class is every row matching content_signal that no sibling guard already suppresses. row_count_overridden counts the rows this export actually suppressed: that class minus the three exempt populations below (block-critical group, rank ≤ 1M, unresolved group). The changed count and the three exempt counts partition the class, so summing the four reconciles to it; rows a sibling guard already suppresses are counted under that guard's block, never here. Three populations ship unchanged and are counted apart, never in row_count_overridden: block_group_exempt_row_count (rows whose label projects to a block-critical policy group — block_critical_group_policy lists the eight; block_group_exempt_groups the ones actually held in that run — kept under the same exposure rule as demoted_label_suppression.block_critical_held until an in-group read clears the group), head_rank_exempt_row_count (rows in the popularity buckets listed by head_rank_exempt_buckets, i.e. the top 1M; a row whose rank is unknown — rank_ladder absent, or no inventory record — is not exempt), and unresolved_group_exempt_row_count (rows held fail-closed because their group could not be resolved; non-zero beside a policy_pack block whose version and sha256 are null means the pack did not load and the guard was inert for that export). Filter-derived (pipeline/*), rule-derived and curated rows are never affected. Same transition rule as 1.8.0/1.10.0/1.11.0: consume the first ≥ 1.14.0 set as a full per-partition replace. Trends to 0 as re-crawls attach a language to these rows or retire them. |
files |
Per-file name, size, and SHA-256 — verify your download against these. |
created_at, export_dir |
When the export completed, and its directory name. |
On accuracy figures: row_count is blended, so it is the wrong denominator.
content_accuracy_denominator_row_count is published precisely so you do not have to
know which lane is answerable — it is the active count, and non-active lanes are
excluded from it by construction.
run_manifest.json — is the snapshot COMPLETE?A full snapshot is delivered as sixteen partition directories
(snapshot_<date>_p00/ … _p15/), each with its own manifest.json — which
proves that one partition, not the set. Listing the prefixes for a date cannot
tell you 14/16 from complete, and a fan-out interrupted near UTC midnight can
even leave a recovered partition under the next day's date.
So each completed snapshot run also publishes one run-level marker:
exports/snapshot_<date>/run_manifest.json
It is written only after all sixteen partition manifests for that date are
present and coherent (each parseable, matching its partition, with a positive
row_count, and all on one feed_schema_version). It carries type
(snapshot_run), date, feed_schema_version, partitions_expected (16),
partitions (each partition's manifest_key and row_count), and
row_count_total. From artifact 1.17.0 the sixteen must also have been tagged from one
news-publisher list, which the marker names as content_purpose_list —
{"sha256": …, "row_count": …}, without the partitions' path — so a set whose
partitions were tagged from two lists gets no marker until it is re-published
(§14).
From artifact 1.7.0 it also rolls the per-partition keys up to run level, when and
only when every one of the sixteen partitions carries them: row_count_curated_total,
rank_ladder, row_count_by_rank_bucket, inventory_total_by_rank_bucket,
coverage_by_rank_bucket, row_count_dead_status_by_rank_bucket,
inventory_dead_status_by_rank_bucket, inventory_never_shippable_by_rank_bucket and
coverage_shippable_by_rank_bucket, and (artifact ≥ 1.14.0) the status-age keys —
status_age_reference_date, row_count_by_status_age, stale_fraction_active_gt_90d
(re-derived from the summed active map, never averaged) and, with a present ladder,
row_count_by_status_age_by_rank_bucket — which roll up only when every partition aged
its rows against the same reference date. A key missing from even one partition is omitted from
the roll-up rather than summed over a subset — a distribution over 14 partitions is
not a distribution over the corpus. These keys never affect whether the marker is
written; the completeness contract above is unchanged.
From artifact 1.14.0 the marker also carries the churn block — tier1_transitions_window,
tier1_transitions_since, tier1_transitions_reason, row_count_tier1_changed,
top_tier1_transitions and policy_group_transitions — read with ONE query over our
category-transition ledger for the
interval between the previous snapshot's marker and this one, never assembled from the
deltas published in between. It is telemetry on the same terms as the roll-up keys: it can
never decide whether the marker is written, and a run that could not measure it publishes
tier1_transitions_reason instead of a number.
tier1_transitions_window is the half-open interval the three counts cover — since is the
PREVIOUS snapshot's marker created_at, until is this marker's — so two consecutive
snapshots tile the timeline and no ledger event is counted by both. since is established
once, on the run that first publishes the date, and a re-run reuses it (see below). That previous marker has
to be the one this chain published for its own date: it must sit at that date's key, declare
that date, and enumerate that date's partition manifest keys under the same prefix. An
object that fails any of those (including a marker for a different snapshot restored to the
key) bounds nothing — the counts are withheld and tier1_transitions_reason says
predecessor marker unreadable, never a number over a window we could not establish.
tier1_transitions_reason is null exactly when the three counts are measured; otherwise
it names the case, and two of them (predecessor marker missing, no 1.14.0 predecessor)
publish an honest 0 / [] / [] because the chain simply starts there.
Tiling needs the chain to be published in date order, and a recovery can break that: a
snapshot date marked for the first time after a later date was already published closes
its window at the moment it is marked, which is past the later snapshot's own created_at,
so its interval would contain that snapshot's entirely. Such a run publishes
a later snapshot's window is already published and withholds the counts — with
tier1_transitions_since: null, so a re-run of it decides the same way — rather than
announce churn you have already been given. The later snapshot's marker, its bounds and its
counts are not touched, and the recovered date still gets its completeness marker. If a
later date's marker cannot be read at all, the run says later snapshot marker unreadable
and withholds for the same reason. That check walks forward one date at a time and is
bounded at five weeks, so a recovery more than five weeks late runs out of candidates
before it reaches the day its own window would close on; it then says
successor scan exhausted before this run's upper bound and withholds too, because the
days it could not ask about may hold a window you have already been given. All three
withhold with tier1_transitions_since: null, so a re-run of the date decides afresh
rather than inheriting a bound that would skip the check.
Use it as the completeness gate: treat a dated snapshot as complete iff
its run_manifest.json exists, and read the sixteen manifest keys from it
rather than globbing the prefix. Its absence on a current delivery means the
set is still incomplete or being recovered — do not ingest a partial set.
Snapshot dates published before this marker existed (up to and including
2026-08-02) have none; absence is only meaningful on deliveries after that.
The marker describes the set as of its created_at, which is the date's
FIRST published stamp: a re-run retains it rather than re-stamping the clock,
because it is also the churn window's upper bound and the next snapshot's lower
bound. The window's own since is retained from that same marker for the
mirror-image reason — it is the interval you were told these counts cover — so
a re-run re-derives neither bound. Without that, a marker for a date
inside the published interval appearing between two runs of the same snapshot
(a re-published older date, an archive copy-back, a run recovered out of order)
would move since forward on the re-run and shrink the counts beside it, with
until unchanged and no reason given. Both values are only retained from an
object that passes the same identity test the previous marker above has to
pass — this date, and this date's partition manifest keys under the same
prefix — so a marker for another set restored onto the key supplies neither
bound; the run stamps its own clock, measures its own window, and the marker is
still written. A re-run that cannot read that object back fails instead — the
marker already on the key is left exactly as it is — so a published boundary
never moves under a successor that has already been delivered. The retained
since is read from
tier1_transitions_since, which a marker carries whether or not it managed to
measure over it — and whatever stopped it, a failed read, a timed-out one or no
ledger configured on the run at all — so a run that could not measure does not
release the bound and hand the run after it back to deriving one: a re-run
publishes the bound it was handed, and a first run of a date publishes the one
it had validated before its read failed or timed out (a first run with no
ledger to read never locates a predecessor, so it publishes null); either way
the interval survives the failure. A date whose marker carries tier1_transitions_since: null — no
previous marker was located at all — has no bound to retain, so a later re-run
of it measures normally. In the rare case that a date is re-published after its
marker was written, the marker is rewritten
when the re-publish completes; per-file integrity is always the sha256 values
inside each partition's own manifest.json — verify your downloads against
those regardless of the marker.
Deltas alone cannot keep you compliant. Removals — GDPR takedowns and suppressions — retract a row, and only a full per-partition replace expresses a retraction; the status-only lane's baseline is likewise snapshot-borne. (Curated rows are not: from artifact ≥ 1.13.2 they ride every delta as well — §5 — which changes nothing about this obligation, since neither removals nor the status-only baseline travel in a delta.) The licence therefore carries this term:
Licensee shall ingest each full snapshot within 14 days of publication, and shall apply it as a per-partition replace rather than a merge. Deltas alone do not convey removals; a Licensee consuming only deltas will retain records WhoisXML API has withdrawn.
The 14 days are set against the platform's 30-day end-to-end erasure target: a withdrawn record leaves the next weekly snapshot (that set starts within seven days and is usually complete the following Monday, UTC), and your 14-day ingest window puts the worst case at about 22 days — inside the target, with margin for our own publishing latency.
Deltas are not partitioned: each one mixes rows from all sixteen partitions and carries no partition column. A full set withdraws a row by no longer carrying it, so replacing one partition at a time removes a withdrawn row only if you know which partition it belongs to — rows a delta added included. Two ways to apply a set:
run_manifest.json exists) in place of everything you hold, delta-applied rows included,
then resume with the deltas as §1.1 says.domain value exactly as published:h = hashtextextended(domain, 0x7A5B22367996DCFD): PostgreSQL's 64-bit hash of the
name's UTF-8 bytes under the seed every hash-partitioned table uses, read as an unsigned
64-bit integer;hash_combine64(0, h), which for a single key is (h + 0x49A0F4DD15E5A8E3) mod 2^64;mod 16, which is the NN of snapshot_<date>_pNN.In PostgreSQL itself the partition is ((hashtextextended(domain, 8816678312871386365) & 15) + 3) & 15
for a text column under a deterministic collation (the default). Anywhere else, this
standard-library Python reproduces it exactly:
def feed_partition(domain: str) -> int:
"""The partition (0-15, the NN of snapshot_<date>_pNN) that holds `domain`."""
m32 = 0xFFFFFFFF
def rot(x, k):
return ((x << k) | (x >> (32 - k))) & m32
def mix(a, b, c):
a = ((a - c) & m32) ^ rot(c, 4)
c = (c + b) & m32
b = ((b - a) & m32) ^ rot(a, 6)
a = (a + c) & m32
c = ((c - b) & m32) ^ rot(b, 8)
b = (b + a) & m32
a = ((a - c) & m32) ^ rot(c, 16)
c = (c + b) & m32
b = ((b - a) & m32) ^ rot(a, 19)
a = (a + c) & m32
c = ((c - b) & m32) ^ rot(b, 4)
b = (b + a) & m32
return a, b, c
def final(a, b, c):
c = ((c ^ b) - rot(b, 14)) & m32
a = ((a ^ c) - rot(c, 11)) & m32
b = ((b ^ a) - rot(a, 25)) & m32
c = ((c ^ b) - rot(b, 16)) & m32
a = ((a ^ c) - rot(c, 4)) & m32
b = ((b ^ a) - rot(a, 14)) & m32
c = ((c ^ b) - rot(b, 24)) & m32
return a, b, c
# 1. hashtextextended(domain, 0x7A5B22367996DCFD): PostgreSQL's 64-bit lookup3.
key = domain.encode("utf-8")
seed = 0x7A5B22367996DCFD
a = b = c = (0x9E3779B9 + 3923095 + len(key)) & m32
a = (a + (seed >> 32)) & m32 # the seed is hashed first, as a 12-byte block
b = (b + (seed & m32)) & m32
a, b, c = mix(a, b, c)
while len(key) >= 12:
a = (a + int.from_bytes(key[0:4], "little")) & m32
b = (b + int.from_bytes(key[4:8], "little")) & m32
c = (c + int.from_bytes(key[8:12], "little")) & m32
a, b, c = mix(a, b, c)
key = key[12:]
tail = key.ljust(11, b"\0") # the last 0-11 bytes; the low byte of c is never written
a = (a + int.from_bytes(tail[0:4], "little")) & m32
b = (b + int.from_bytes(tail[4:8], "little")) & m32
c = (c + (int.from_bytes(tail[8:11], "little") << 8)) & m32
a, b, c = final(a, b, c)
h = (b << 32) | c
# 2. hash_combine64(0, h), then 3. mod 16.
return ((h + 0x49A0F4DD15E5A8E3) & 0xFFFFFFFFFFFFFFFF) % 16
Check it on your own delivery before relying on it: every domain in
snapshot_<date>_p07.parquet returns 7.
site_purpose_* block and the policy_group projectionColumns 27–38 (artifact ≥ 1.12.0) add two things: a reserved second axis and a projection over the label. Neither changes any existing column.
site_purpose_* block (columns 27–35)The stored label (tier1..tier4) is the primary label under the purpose-first single-label
convention (§2). The site_purpose_* columns are reserved for a separately modelled
business-purpose answer, produced by a different model against the same taxonomy — nothing
populates them today — in the same IAB v3.1 vocabulary (Shopping for a store, Attractions >
Casinos & Gambling for a sportsbook), with its own confidence, its own provenance
(site_purpose_model_version) and a site_purpose_relation to the primary label
(same_path / same_tier1 / different_tier1).
At 1.12.0 no purpose pass has run: every one of these columns is NULL on every row except
site_purpose_status, which is never NULL and tells you why the rest is empty:
site_purpose_status |
Meaning at 1.12.0 |
|---|---|
not_available |
Not assessed. Every live row until a purpose pass runs. Do not read this as "no purpose". |
undefined |
The domain has no separate business purpose: the parked, inactive and dead lanes, and rows the existing parked detectors flagged (the parking-stub fingerprint, except on a popularity-bucket-0 or 1 capture from 2026-07-31; the parked-demoted label). Derived from the same status evidence as domain_status, never from a model. Sensitive-filtered rows are not undefined — they have a purpose and read not_available until labeled. |
labeled |
The purpose columns are populated. Does not occur at 1.12.0; site_purpose_model in the manifest is null until it does. |
The columns are reserved now so that one schema bump, not two, carries both features and you
build your table once. row_count_by_site_purpose_status in the manifest shows the split
without a scan. When a purpose pass lands, the purpose columns are tracked like the
category columns (a purpose-label change alone produces a delta row).
These nine columns keep their names and positions: they will not be renamed or repurposed,
under the §1 column-order contract. Eight stay NULL and
site_purpose_status keeps its documented values until a purpose pass is announced in the
Website Categorization feed changelog. A second axis, if one ever ships, arrives as new columns
appended at the end of the then-current schema, never by changing what these hold.
policy_group, policy_basis, policy_pack_version (columns 36–38)policy_group is the row's label rolled up into one of the pack's 47 policy groups —
the filtering vocabulary (adult, gambling, piracy, drugs, weapons, hate,
dating, social_networking, shopping, …) that DNS-filter, secure-web-gateway and
endpoint policies already use. policy_basis says which axis it was projected from
(primary = the tier path; site_purpose once that axis is labeled, which takes
precedence; safety from artifact 1.14.0, which takes precedence over both).
policy_pack_version is the pack and mapping version the row was projected with. The full
group list, each group's definition and the IAB nodes it covers are in
website-categorization-policy-pack-v1.md and, from artifact 1.14.0,
website-categorization-policy-pack-v2.md.
Which pack, by artifact version. policy_v1@2026.09.2 at 1.12.0–1.13.x;
policy_v2@2026.09.1 from 1.14.0. policy_v2 changes the projection in two ways, both
of them visible in this column: the brand-safety axis is read before the label (§13), and
the group news is retired in favour of current_events — the same three
current-affairs roots (Crime, Disasters, War and Conflicts), an id that says what they
are, and the reason the pack ID moved rather than the mapping version. A policy keyed on
news matches nothing from 1.14.0 on; see the Website Categorization feed changelog. No
other path changes group and the group count is unchanged.
Why it exists: IAB v3.1 scatters what a blocking policy treats as one thing. Gambling
alone lives at Attractions > Casinos & Gambling, Shopping > Lotteries and
Scratchcards, Sports > Poker and Professional Gambling and a Video Gaming genre —
an integration that blocks the Sensitive Topics subtree blocks none of them. The three
real-money nodes project to gambling and the video-game genre (simulated gambling, no
real-money play) to gambling_games (mapping 2026.09.2); a policy is one equality test per
group on this column.
How it is computed — and what that means for you:
policy_v1) it was a pure function of the stored label: the deepest
rule matching the tier path won (a label stored as Attractions > Casinos & Gambling →
gambling; one that stopped at Attractions → travel), it read nothing else about the
domain, and policy_basis was primary on every projected row.policy_v2) the label is no longer the only input. The projection reads
the brand-safety axis first — the flagged themes in safety_flags (§13) — then
site_purpose (still unlabeled, so unreachable today), then the tier path, and
policy_basis names the axis that answered. It still never changes any other column.
Three consequences a policy has to be written against:gambling flagged on a row labelled Automotive reads policy_group = gambling,
policy_basis = safety. So policy_group need not correspond to tier1 at all,
and an integration that re-derives the group from the tier path will disagree with the
column on exactly these rows.sensitive group and no flagged theme names that group, the label
answers and policy_basis reads primary; the verdict stays visible in safety_flags.
The one exception is the theme-unknown verdict (sensitive, theme not identified),
which is never more specific than a theme-specific one — an adult flag on a bare
Sensitive Topics label reads adult / safety.Entertainment > Movies reads entertainment.
Per-group precision and recall have not been measured, and no accuracy figure attaches
to any group. Evaluate on your own domains before blocking.When the three columns are NULL. Through 1.13.x that was exactly where tier1 was
NULL or was not an IAB v3.1 tier-1 name. From 1.14.0 that is no longer the rule: a row
carrying a flagged theme the pack maps to a group resolves from the safety axis alone, so
policy_group, policy_basis and policy_pack_version are populated (basis safety)
even where tier1 is NULL or unresolvable. From 1.14.0 the three columns are NULL
exactly where:
safety_flags and safety_detail are blanked with them; safety_status is
preserved, and is the only reading that survives on a suppressed row; ortier1
is NULL or is not an IAB v3.1 tier-1 name.A deeper tier that does not resolve (an unknown tier2/tier3/tier4 name) falls back
to the deepest resolvable prefix — the same rule as openrtb_cat, which keeps the ids of
the resolvable prefix — so a group read from the label can only ever be the parent's,
never a more specific claim than the label supports. None of the three is carried into the
last_known_* block.
The policy columns are derived, not tracked. The mapping is versioned independently of
the schema: manifest.json carries policy_pack: {id, version, sha256}, and a pack edit
(say, moving casino-themed video games out of gambling) bumps the version and is listed
in the changelog with no column change. Because it touches every row, a pack version
change ships via the next full snapshot, never as a delta: every delta carries the
policy_pack of the snapshot it extends, a delta whose pack differs from its
predecessor is a chain gap, and the exporter refuses to write one. So the first
≥ 1.12.0 artifact you ingest must be a snapshot, and after any policy_pack change you
will receive a snapshot before the next delta. The pack id is half of that identity,
not decoration: policy_v1@2026.09.1 and policy_v2@2026.09.1 carry the same version
and different mappings, so compare policy_pack as the id + version pair — the
<pack>@<version> form the per-row policy_pack_version column already uses. Pin that
pair if your policy depends on exact membership.
policy_confidence_basis and policy_group_set (columns 42–43)Two columns that make the policy block usable without a lookup table.
policy_confidence_basis says what column 7 / column 24 IS on this row. §5
documents that confidence is not one scale — a trained classifier's probability, a
filter's internal score, a keyword density and a hand-chosen trust value all live in the
same column, and until now the only way to tell them apart was to know the model_name
families by heart. The value is a closed enum:
| Value | What the number is |
|---|---|
model_calibrated |
A measured probability: calibrated_confidence is populated on this row, and it is the number to threshold on. |
model_raw |
A trained classifier's own, uncalibrated probability. |
filter |
A content filter decided the row outright; the number is that filter's internal score, not a probability for the tier path. |
rule |
A deterministic rule decided or rewrote the label (tld_rule/*, schema_rule/*, pipeline/vendor_rule). Provenance, not scale: a tld_rule/* row carries the rule's fixed trust value while a pipeline/vendor_rule row carries the classifier's own probability unchanged. |
curated |
A hand-written verdict — a curated list entry, or an accepted human-feedback review; a fixed trust value chosen when it was written, never measured against outcomes. |
carry_over |
Carried over from a previous-generation system rather than produced for this label. |
name_only |
Two language models' self-reported probability (the lower of the two) that the domain NAME identifies the site's category — no page was read (writer family llm-name/*, artifact ≥ 1.15.0). Never model_calibrated; band capped at MEDIUM. |
none |
No label to attribute the number to, or no writer recorded. |
The writer family is decided first: a filter, rule, curated, carry-over or name-only row
keeps its family whatever the calibrated column holds, so model_calibrated appears only where the
row would otherwise read model_raw. Two identities hold on any artifact:
count(policy_confidence_basis = 'model_calibrated') equals row_count_calibrated, and
every *_filter writer reads filter.
policy_basis (column 37) is a different question — which AXIS the group came from — and
from 1.14.0 its documented values are primary, site_purpose and safety. role is
reserved for a later release and is not emitted at 1.14.0. If you validate column 37
against a strict enum, add safety before ingesting a 1.14.0 artifact; this is the one
breaking value addition in the release, and it is confined to an opt-in block.
policy_group_set is the group plus its declared union. Blocking "gambling-shaped"
content means matching gambling, gambling_games and harmful_content; blocking drugs
means drugs, alcohol and tobacco. The pack declares those unions, and this column
materializes them per row, own group first, then its partners in pack order:
policy_group = "gambling" → policy_group_set = ["gambling", "gambling_games", "harmful_content"]
policy_group = "shopping" → policy_group_set = ["shopping"]
One array_contains replaces a join against the taxonomy endpoint. It is NULL exactly
where policy_group is NULL, so the two columns can never disagree about whether the
projection ran.
site_purpose_* axis (NULL through 1.13.x except site_purpose_status);
columns 39–44 were appended at 1.14.0 and columns 45–46 at 1.17.0.content_purpose_id (column 45) is 1020, IAB Content Purpose = News, on the domains of
a curated list of known news publishers and NULL everywhere else — a list, not news-site
coverage, and NULL is unknown, never "not news". The topic columns are unchanged
(§14).domain_status has six values. parked / inactive / dead carry no category
at all; active, blocked and uncategorizable may. active is not a promise of a label — test
tier1 IS NOT NULL instead (§3).calibrated_confidence (a probability), not
confidence (an uncalibrated model score). See §5.calibrated_confidence is populated on a subset of categorized rows. tier1 IS
NULL implies it is NULL; the converse does not hold — see
§5.calibration_segment = '__global__' marks the weakest calibrated rows.blocked = live but unreadable. Not parked, not dead — and it keeps its
category and its calibrated value from the previous crawl. A 304 Not Modified
answer is not blocked (from the 2026-09-17 set).classified_at = when we processed it. status_observed_at = when the crawl saw
the status. Neither = when the content changed.confidence is not one scale: on filter rows (*/st_filter, */gambling_filter,
*/adult_filter) and rule/lookup rows (curated/*, tld_rule/*, schema_rule/*,
v3_historical_fallback) it is a component's own score, not a category probability —
check model_name before comparing or thresholding (§5).pipeline/vendor_rule rows carry the classifier's confidence under a label the rule
chose (Business and Finance → Technology & Computing on vendor sites); calibrated_*
is NULL there (§5).curated/* rows are hand-vetted, tier = 3, always domain_status = active by
assertion, always NULL calibrated_*, and ride every snapshot and every delta
(deltas from artifact ≥ 1.13.2) — apply them by domain, never by classified_at order,
because a curated row is usually dated earlier than the row it replaces
(§5).model_version may carry a +variant suffix (v1+ml_en) — compare on the base
version, never with = (§5).openrtb_cat is an array — JSON-encoded in CSV, native list in Parquet.policy_group is the policy-pack rollup (gambling, adult, …), versioned by
policy_pack_version / policy_pack.version. Below 1.14.0 it was the policy_v1 rollup
of the tier path and nothing else, so it was NULL exactly where that path did not resolve;
from 1.14.0 the pack is policy_v2, which reads safety_flags first and renames
news to current_events. So from 1.14.0 a flagged row carries a group even where
tier1 is NULL, and the column is NULL only on a suppressed row (parked / inactive /
dead and the stub guards) or where neither axis resolves — never read tier1 IS NULL as
"no group".
site_purpose_status is never NULL
(§11).last_known_* is historical and sparse. Read your delivery's own
row_count_with_last_known_category; figures quoted in documentation age
(§8).exports/snapshot_<date>/run_manifest.json
exists — read the sixteen partition manifests from it instead of globbing
(§10).safety_status (column 39) is never NULL and is kept even on a suppressed row;
safety_flags / safety_detail are NULLed with the category block. safety_status
and safety_flags read brand-safety verdicts only
(§13).policy_confidence_basis (column 42) tells you what the number in column 7 / 24 is
without knowing the model_name families; policy_basis (column 37) gains the value
safety at 1.14.0 — a breaking value addition if you validate it against a strict enum
(§11).policy_group_set (column 43) is the group plus its declared union — match one
list instead of rebuilding gambling ∪ gambling_games ∪ harmful_content yourself.language_primary (column 44) is the BCP-47 primary subtag of column 14. eng stays
eng — it is a valid three-letter subtag and is deliberately not folded to en,
because the two spellings come from different detectors and folding them would claim an
equivalence we have not measured. Column 14 stays raw.Three columns, appended at artifact 1.14.0, carrying what our brand-safety filters concluded about a domain — separately from what its category says.
Why it is a separate axis: a category answers "what is this site for?", and a blocking
policy usually also needs "did anything flag it?". Those are different questions, and they
disagree in both directions — a gambling operator labelled Attractions > Casinos &
Gambling is normally flagged, a news outlet reporting on gambling normally is not, and a
parked ex-gambling domain can carry a flag with no category at all. Neither filter is exact:
a model-adjudicated read of the 2026-10-18 set found the sensitive-content filter flagging
some clearly legitimate sites at the head. Reading the second question off tier1 or
off model_name is what customers were doing, and it under-blocks by construction.
| Column | Type | Value |
|---|---|---|
39 safety_status |
string, never NULL |
flagged — at least one brand-safety filter raised a block signal; assessed_clean — a filter looked and raised none; not_assessed — nothing looked at this domain. |
40 safety_flags |
list<string> |
The flagged themes, e.g. ["gambling"]. On an unsuppressed row safety_status = 'flagged' iff this list is non-empty, and the list is an empty list rather than NULL. NULL on a suppressed row — which keeps its safety_status, so a NULL list is not evidence the row is unflagged. |
41 safety_detail |
string (JSON object) |
One entry per filter family that ran, carrying its status, score, scale, theme and version — a family reporting not_assessed is retained here, so a populated detail is not itself evidence that anything assessed the domain. NULL on a suppressed row, and on a row where no verdict was materialized at all. |
safety_status is preserved through suppression, like site_purpose_status. A parked
or dead row ships no category, but "a brand-safety filter looked at this domain" stays true
whatever the feed does with the label — so column 39 keeps its value while columns 40 and 41
go NULL with the rest of the category block. A flagged status on a parked row is a fact
about the assessment, not a category claim; the flags that would substantiate it are not
published on that row, by design.
What counts as a brand-safety verdict — and what does not. safety_status and
safety_flags are computed over brand-safety verdicts only. The vendor rule is a ROLE
classification, not a brand-safety one: it decides whether a Business and Finance label
belongs on a company that sells to that industry rather than operating in it. Its
verdict appears in safety_detail under the family vendor and nowhere else — never in
safety_flags, never enough to make a row flagged, and never enough to move a domain out
of not_assessed. A role basis is reserved for a later release. Shadow themes
(measurements whose precision has not been read yet, such as adult_nonadjacent) are
recorded in safety_detail on the same terms: they are not block signals.
Rows written before the verdict store. A domain's verdicts are stored from the 1.14.0
release onwards. Rows whose last write predates it are derived from the row's existing
model_name and tier2 and carry a legacy version marker in safety_detail. That
derivation exists so the axis is complete on the first artifact rather than reading
not_assessed for every pre-existing filter row — a domain last crawled 80 days ago is
90-day-shielded and would otherwise never gain a verdict. For those rows the flag is a
re-statement of what column 10 already told you, not new evidence; safety_detail's version
marker is how you tell the two apart.
Reading it. safety_status = 'flagged' is the coarse gate. safety_flags is the one to
key a block list on if you want every flagged row regardless of where its label landed —
policy_group under policy_v2 reads the safety axis first, so a flagged row usually
carries the theme's group, but a row whose label already resolves to a different sensitive
group keeps that group instead. safety_detail is diagnostic: read it to understand a
verdict, not to build a filter, because its shape is per-family and will gain families.
Two columns, appended at artifact 1.17.0, that say a domain is a news publisher in the
IAB's own vocabulary for it. The first artifact carrying them is the Wednesday 2026-11-04 delta
(delta_2026-11-03_100000Z); the first full set carrying them is the Sunday 2026-11-08 set.
Why a separate pair of columns. IAB Content Taxonomy v3.1 has no News topic, so a news
outlet's tier1–tier4 name what it covers — Politics under the single-label convention
(§2), or Sports, Business and Finance and so on. The IAB Tech Lab
Content Taxonomy 3.0 Implementation Guide (section 8, Descriptive Vectors) expresses news as a
Content Purpose, carried next to the topic and never in place of it:
Content Purpose > Informational > News, unique id 1020. These two columns carry that vector.
The topic columns are unchanged on every row: filter news on column 45, and keep your topic
rules on columns 2–6.
| Column | Type | Value |
|---|---|---|
45 content_purpose_id |
string |
1020, or NULL. A Descriptive Vector id, not a Content Taxonomy v3.1 id: it never appears in iab_id or openrtb_cat, and no tier name corresponds to it. |
46 content_purpose_source |
string |
Which rule set column 45: curated, or NULL exactly where column 45 is NULL. schema_org is a reserved value — see below. |
Where the value comes from: a list, and only a list. In the feed the only source is a
curated list of known news publishers maintained by our team; the list is versioned, and
every change to it is reviewed before release. Every domain on it is also one of our hand-vetted
curated/* rows (§5), so the value appears only on
curated/* rows. No model sets these columns and no page is read to set them. That makes this
a list, not news-site coverage: most news sites in the feed are not on it and read NULL, and
will until a later release adds another source. content_purpose_list.row_count in any manifest
says how many domains the list names (§10).
NULL means unknown, never "not news". A NULL in column 45 says we hold no news-publisher statement about the domain. It is not a statement that the site is not a news outlet, and it must not be used as one — for example, to build an "exclude news" list out of the NULL rows.
schema_org is reserved. The real-time API's opt-in content_purpose block
(include=content_purpose) has a second source, schema_org: the site's own schema.org
NewsMediaOrganization markup, read from the crawled page. The feed does not emit it in
1.17.0, because the export carries no page markup. It is named now so a loader that validates
column 46 against a strict enum can accept it ahead of time; a release that starts emitting it
will be announced in the Website Categorization feed changelog first.
The same answer as the API, where both give one. For a domain on the list, column 45 and the
API block's id are both 1020, and column 46 and its source are both curated. A domain the
API tags from its markup alone (schema_org) reads NULL here. The legacy v1 response field that
is also called content_purpose is a different thing: reserved, null in every deployment, and
not carried by these columns.
How it reaches you. Curated rows ride every snapshot and every delta whole, so the deltas are enough: if you consume deltas only, add the two columns as NULL to your table and apply the first 1.17.0 delta — the two columns then hold exactly what a 1.17.0 full set publishes in them. When the list changes, the next delta re-sends every curated row with its current value, so a domain taken off the list reads NULL on your side after that delta. A domain taken off our curated list altogether stops riding the deltas, so your copy keeps its last curated row, tag included, until the domain ships again from our other lanes — in a later delta or the next full set, with columns 45–46 NULL — or a full set no longer carries it (§10).
Suppression. Both columns belong to the content-category block, so they are NULL on any row
whose category is withheld (§9). A curated row is
always active (§3), so this removes no value today.
Counting it. row_count_by_content_purpose_source counts rows per column 46 value, and
content_purpose_list names the list the values were read from — its sha256 and its
row_count (§10). On a delta, and on a full set's run marker (which sums
the sixteen partitions), the curated count equals the list's row_count unless a listed
domain did not ship as a curated row in that artifact — a takedown withholds it, for example. A
partition manifest counts only its own partition's tagged rows. The run marker also names the
one list the whole set was tagged from; a set whose partitions were tagged from two different
lists is not marked complete.
We are here to listen. For a quick response, please select your request type. By submitting a request, you agree to our Terms of Service and Privacy Policy.