# Confidence scoring

URL: https://igregulator.io/docs/confidence/
Markdown: https://igregulator.io/docs/confidence.md

> How /v1/check decides its verdict and confidence: which host answers, domain or name match, why confidence is not validity, and when it refuses to guess.

The `/v1/check` endpoint returns a `match` object with a `confidence`
field. This page explains what each level means, how we pick it, and
how UIs should render it.

**`confidence` is about the match, not the licence.** It says how sure
we are *which* licence the domain belongs to — never whether that
licence is in force. Only `match.status: "active"` means licensed now,
and `match.domain_status: "delisted"` means the regulator no longer
lists this domain on that licence. The top-level `verdict` puts these
together for you.

## verdict — the answer in one field

Every `/v1/check` response (and every [batch](https://igregulator.io/docs/batch/) row that is not an
`error`) carries a `verdict` and a `verdict_detail`. The verdict is derived by
fixed rules, checked in this order — the first that applies is the answer:

| `verdict` | When | What it means |
| --- | --- | --- |
| `generic_term` | `match` is `null`, `match_absence_reason: generic_term` | A generic gambling label we won't map to one operator. The domain is on no licence we hold — it would have matched `domain_exact` otherwise. |
| `related_host_listed` | `match` is `null`, `match_absence_reason: shared_registrable`, and at least one host in [`related_hosts[]`](https://igregulator.io/docs/confidence/#related_hosts--hosts-a-register-does-list) is listed now | Your host — and its `www.` counterpart — is on no licence we hold; other hosts on the same registrable domain are, under more than one operator, so we tie your host to none of them. |
| `not_found` | `match` is `null`, `match_absence_reason: no_record_found` — or `shared_registrable` with no related host listed now | In none of the registers we cover. Not a finding that the site is unlicensed. |
| `name_match_only` | `confidence` is `medium` or `low` | A name resembled an operator's; no licence we hold lists this domain. `status` is that operator's, not this site's. |
| `licence_not_active` | `status` is not `active` — whether or not the regulator still lists the domain on it | `verdict_detail` names the status: `surrendered`, `expired`, `revoked`, `suspended`, `pending`, `not_in_register` or `unknown` — and, if the regulator no longer lists the domain on that licence either, says that too. Only `revoked`/`suspended` are enforcement decisions. |
| `domain_not_listed` | `status: active`, `domain_status` is not `active` (`delisted`) | The regulator no longer lists this domain on the matched licence, which is itself active — that licence does not cover this site. |
| `related_host_listed` | `match.match_type: related_host` — `status: active`, that host listed | Your host is on no licence we hold; `match` describes another host on the same registrable domain (`match.matched_domain`), the only operator's there. Never `licensed`: a listing covers the host the regulator names. |
| `licensed_provisional` | `status: active`, domain listed, a `status_qualifier` is set | Licensed now, provisionally — today a Curaçao licence the CGA keeps in force pending its final assessment. |
| `licensed` | `status: active`, `domain_status: active`, no qualifier | The regulator lists this domain — or its `www.` counterpart, the same site — on an active licence. |

`related_host_listed` appears twice because the related-host stage has two
outcomes ([which host answers](https://igregulator.io/docs/confidence/#which-host-answers--exact-www-then-the-rest-of-the-domain)):
one operator's host stands in as `match`, or several operators' hosts are listed in
`related_hosts[]` with no `match`. Either way it is not an answer about your host.

The licence comes before the listing: a revoked, suspended or expired licence is
`licence_not_active` even when the domain has also been de-listed, so the
revocation is never hidden behind "no longer listed".

Only `licensed` and `licensed_provisional` mean "licensed now". Every other value
is "not confirmed as licensed by the registers we cover" — and none of them is a
finding that the site is unlicensed.

For a `?license_number=` query there is no domain, so the verdict is about the
licence alone: `licensed`, `licensed_provisional`, `licence_not_active` or
`not_found`.

`verdict` describes `match` — the link the regulator lists now. A dual-licensed
domain keeps its other links, each with its own `status` and `domain_status`, in
[`jurisdictions[]`](https://igregulator.io/docs/confidence/#jurisdictions--dual-licensed-domains), and `verdict_detail`
says how many there are. The verdict is about the licence *today*, even when you
pass [`as_of`](https://igregulator.io/docs/point-in-time/): then `verdict_detail` leads with the answer
for your date ("On 2026-03-01 we were not yet tracking … licence …, so its status then
is unknown. Today: …") and `as_of` carries it in fields.

`verdict_detail` is one sentence written to be quoted as it stands:

> The Anjouan Gaming Authority no longer lists spinlu.com on the licence held by
> 3-102-947207 SRL (licence ALSI-202602010-FI1), which is itself active — that
> licence does not cover this site.

It names the regulator (`match.regulator_name`), the operator and the licence —
but never our Kahnawake / Tobique / Isle of Man reference as if the regulator
had issued it (`match.license_reference_is_ours`); dates the read that listed
the domain ("as of our read on …" — for a Curaçao or Tobique link that can be
the certificate or seal read, not the register read), and when the register
behind the answer was last read outside its freshness window, names that register
and the date of that read (`_meta.register`); scopes every miss to the registers
we cover and names the stale ones (`_meta.stale_jurisdictions`); and never calls
anything "unlicensed". Branch on `verdict`; quote `verdict_detail`. The wording
may improve over time; a verdict value, once published, keeps its meaning.

```js
const body = await (await fetch(
  'https://api.igregulator.io/v1/check?domain=bet365.com',
)).json();
switch (body.verdict) {
  case 'licensed':
    approve(body.verdict_detail);
    break;
  case 'licensed_provisional': // in force, provisionally — accept per your policy, and re-check
    approveProvisionally(body.verdict_detail);
    break;
  default: // everything else is "not confirmed as licensed": review it, quote the sentence
    review(body.verdict, body.verdict_detail);
}
```

Treat a `verdict` value you don't know like the `default` branch: values can be
added (`related_host_listed` was, after the first seven), and no new one will
ever mean "licensed".

## The three levels

| `confidence` | What it means | Render as |
| --- | --- | --- |
| `high` | A register lists this host — or its `www.` counterpart — on a licence (or, for `?license_number=`, the register has that licence). | We're sure which licence this domain belongs to. Read `status` (only `active` = licensed now) and `domain_status` before saying anything about the site. |
| `medium` | Domain root matched a trading name or operator name. We can identify the operator but can't *prove* this domain is theirs. | Amber / neutral. "Likely operated by X" phrasing — and `status` is X's licence, not this site's. |
| `low` | A weak fuzzy match: an operator is returned, but below the strong-similarity bar. | Gray / warning. "We can't confirm this domain." |

On a miss (`match: null`) the top-level `confidence` is **absent** —
treat it as `none` — or `low` when the domain root is a generic gambling
term (`casino.org`, `poker.com`) that too many operators share to pick
one. [Batch](https://igregulator.io/docs/batch/) rows always carry `confidence`, with `none`
spelled out on a miss.

## Why a query missed: `match_absence_reason`

When `match` is `null`, `confidence` on its own is ambiguous — it used to
conflate "generic term" with "we checked and it isn't there". So on a miss
the response says why, in `match_absence_reason` (present **only** when `match`
is `null`), and which registers it checked, in `checked_jurisdictions`, so you
can phrase the answer precisely instead of guessing:

| `match_absence_reason` | Meaning | Say to your user |
| --- | --- | --- |
| `generic_term` | The label is an ultra-generic gambling word (`casino.org`); we can't map it to one operator. | "Can't identify a specific operator from this domain." |
| `no_record_found` | A specific query we checked against **every** covered register and did not find. | "Not found in any of the N jurisdictions iGregulator covers." — **never** an unqualified "unlicensed". |
| `shared_registrable` | Your host is on no licence; other hosts on its registrable domain are stored, linked to more than one operator — so we tie your host to none of them. Comes with `related_hosts[]`; the verdict is `related_host_listed` when one of them is listed now, else `not_found`. | "This exact host isn't listed; these other hosts on the same domain are, under different operators." |

`checked_jurisdictions` — the exact register codes we checked (e.g.
`["AN","CW","IOM","KH","MGA","TGC","UKGC"]`) — accompanies every answer that does
not tie your host itself to a licence: a miss, a name match (`name_match_only`),
and a related-host answer. A "not licensed" claim is always scoped to our
coverage, never stated as an absolute.

```json
{
  "query": { "domain": "some-unknown-site.com" },
  "verdict": "not_found",
  "verdict_detail": "some-unknown-site.com was not found in the registers of the 7 jurisdictions we cover (AN, CW, IOM, KH, MGA, TGC, UKGC) — not a finding that it is unlicensed; our last read of TGC (2026-09-21) is past its freshness window, so a recent listing there may be missing.",
  "match": null,
  "alternatives": [],
  "match_absence_reason": "no_record_found",
  "checked_jurisdictions": ["AN", "CW", "IOM", "KH", "MGA", "TGC", "UKGC"],
  "_meta": {
    "checked_at": "2026-09-30T18:40:00.000Z",
    "stale_jurisdictions": [{ "code": "TGC", "last_read_at": "2026-09-21T04:15:08.476Z" }]
  }
}
```

(`_meta` trimmed to the fields that matter here.) `stale_jurisdictions` lists the
covered registers whose last read is past its freshness window — a domain a
regulator listed since then is not in our data yet, so each entry weakens the
"not found". It is `[]` when every register is fresh.

A generic label is the one miss that carries a `confidence`:

```json
{
  "query": { "domain": "casino.org" },
  "verdict": "generic_term",
  "verdict_detail": "casino.org is on no licence we hold in the 7 jurisdictions we cover (AN, CW, IOM, KH, MGA, TGC, UKGC), and its name contains the generic gambling term \"casino\", so we will not guess an operator from it — not a finding that it is unlicensed.",
  "match": null,
  "alternatives": [],
  "confidence": "low",
  "match_absence_reason": "generic_term",
  "checked_jurisdictions": ["AN", "CW", "IOM", "KH", "MGA", "TGC", "UKGC"]
}
```

`match_absence_reason` is **absent** when there's a match — check
`match === null` first, then branch on `match_absence_reason` (or simply branch
on `verdict`, which already did).

## Which host answers — exact, `www.`, then the rest of the domain

A register lists hosts, and `/v1/check` answers for the host you send. It looks,
in this order, for:

1. **The site: the exact host and its `www.` counterpart.** `www.example.com`
   and `example.com` are the same site, and registers list either spelling (the
   UKGC lists `www.betfair.com`, Anjouan `spinsup.com`). The links of both are
   ranked together, so both spellings get the same answer — a full `domain_exact`
   match, with `match.matched_domain` naming the host the register lists
   (`paddypower.com` answers for `www.paddypower.com`). Only a leading `www.` is
   dropped or added this way.
2. **Other hosts on the same registrable domain** — the domain under its public
   suffix: `bet365.com` for `sports.bet365.com`, `example.co.uk` for
   `m.example.co.uk`. These are **never** taken as the host you asked about: a
   listing covers the host the regulator names, not every subdomain someone can
   make up, and a subdomain can be a different site run by a different company.
   So the answer is never `licensed`, and `related_hosts[]` names those hosts:
   - **One operator** holds them: `match` describes that operator's best host
     there (`match.match_type: related_host`, `match.matched_domain`), so you can
     see which licence it is. The verdict is `related_host_listed` — or
     `licence_not_active` / `domain_not_listed` when that licence is not active or
     that host is de-listed.
   - **Several operators** hold them: no `match`, `match_absence_reason:
     shared_registrable`. The verdict is `related_host_listed` when at least one
     of them is listed now (an `active` link under a licence we hold), else
     `not_found`.

   The public suffix list we use includes its private section, so on a shared
   hosting platform (`*.raffleentry.org.uk`, `*.it.com`) each customer's host is
   its own registrable domain — one customer's listing says nothing about a
   neighbour's.
3. **A name match** — only when step 2 had nothing to answer with (no other
   host on the registrable domain is stored, or the one operator holding them
   has no licence we hold): the domain's label against operators' trading and
   company names (`name_match_only`), unless the label is a generic gambling
   word (`generic_term`). Hosts shared by several operators never fall through
   to a name match.
4. Otherwise, `not_found`.

## related_hosts[] — hosts a register does list

Whenever step 2 answered, the response (and the [batch](https://igregulator.io/docs/batch/) row)
carries `related_hosts[]`: up to 10 hosts on your host's registrable domain, best
first, each with the operator it is linked to.

```json
{
  "query": { "domain": "sports.bet365.com" },
  "verdict": "related_host_listed",
  "match": null,
  "match_absence_reason": "shared_registrable",
  "checked_jurisdictions": ["AN", "CW", "IOM", "KH", "MGA", "TGC", "UKGC"],
  "related_hosts": [
    { "host": "bet365.com", "operator": "Hillside (New Media Malta) Plc", "operator_slug": "hillside-new-media-malta-plc", "jurisdiction": "MGA", "domain_status": "active" },
    { "host": "bet365.com", "operator": "Hillside (UK Gaming) ENC", "operator_slug": "hillside-uk-gaming-enc", "jurisdiction": "UKGC", "domain_status": "active" },
    { "host": "bet365.com", "operator": "Hillside (UK Sports) ENC", "operator_slug": "hillside-uk-sports-enc", "jurisdiction": "UKGC", "domain_status": "active" }
  ]
}
```

(Trimmed to the fields that matter here.) Each entry is a fact about *that* host:
`domain_status` is its listing under that operator, and `jurisdiction` the
regulator of the licence that host's link reports ([which one](https://igregulator.io/docs/confidence/#which-licence-a-domain-link-reports)) — not a licence status. If one of them
is the site you meant, check that host: its own answer carries the licence and its
status. If none is, report your host as not listed in the registers we cover;
never carry a related host's licence over to it. `checked_jurisdictions` and
`_meta.stale_jurisdictions` come with every related-host answer, as with a miss.

## Hostnames we accept

`domain` is a hostname, not a URL. Before it is looked up it is trimmed,
lowercased, stripped of one trailing dot (`bet365.com.`, the fully-qualified form,
is `bet365.com`), and an internationalised name is converted to the punycode form
registers and DNS use (`bücher.example` → `xn--bcher-kva.example`). What is left
must be at least two labels of letters, digits and hyphens (no underscores), each
1–63 characters, 253 in all. Nothing else is repaired: a scheme, path or port
(`https://bet365.com/`) is `400 not_a_valid_hostname` on `GET /v1/check`, and more
than 253 characters `400 hostname_too_long`; in a batch, either is an `error` row.

- **`query.input`** — when what you sent differs from what we looked up (capitals,
  a trailing dot, a Unicode name), `query.input` is your string and `query.domain`
  the hostname. A batch row always carries both.
- **IP addresses** — no register lists one. An IPv4 address passes the syntax
  check and answers `not_found`; an IPv6 address is not a valid hostname.

## match_type

Tells you *how* we arrived at the match, useful for debugging and UX
differentiation.

| `match_type` | Source |
| --- | --- |
| `domain_exact` | A regulator source lists the host — or its `www.` counterpart (`matched_domain` says which) — on the licence. Carries `domain_association` (`direct` or `white_label`). |
| `related_host` | Your host is not stored; one operator's other host on the same registrable domain is, and `match` describes that host (`matched_domain`). The verdict is never `licensed` — see [which host answers](https://igregulator.io/docs/confidence/#which-host-answers--exact-www-then-the-rest-of-the-domain). |
| `license_number` | A `?license_number=` query found the licence in a register we cover. No domain, so `domain_association`, `domain_status` and `matched_domain` are `null`. |
| `trading_name_fuzzy` | Trigram similarity ≥ 0.55 against `operators.trading_names[]` after stripping the TLD. Used when the domain isn't registered but the brand exists. 0.55 was picked empirically against the UKGC register: it catches legitimate variants (`paddypower` ↔ `paddy-power`, `skybet` ↔ `sky-bet`) while rejecting the long tail of single-syllable collisions (`gold`, `star`, `royal`) where the label is too generic to mean one operator. Below 0.55 we land in `low`-confidence territory either way; above it the trigger is stable. |
| `name_similarity` | Last-chance similarity against `operators.display_name` — rarely fires for B2C domains, useful when no trading name was populated upstream. |

## domain_association

When `match_type = domain_exact`, we differentiate:

- **`direct`** — the licensee runs the site themselves. The `operator` field is the company your end-user is gambling with.
- **`white_label`** — the licensee has authorised a third-party brand to trade on the domain under their permit. The `operator` field is the *licensee*, not the brand. UK-licensed white-label arrangements are legal and common; surfacing the relationship lets you show "operated by Brand X under ProgressPlay's UKGC permit".

Fuzzy matches (`trading_name_fuzzy`, `name_similarity`) don't populate
`domain_association` — we don't have a domain row to read it from, so
the field is `null`.

## domain_status vs status

`status` is the **licence**; `domain_status` is the **domain**. They
move independently: a regulator can withdraw one site from a licensee's
permitted surface while the licence itself stays active. `domain_status:
"delisted"` means exactly that — check it before treating `status:
"active"` as a green light for the site you were asked about.

## verification_url — check us against the regulator

Where the regulator publishes a per-domain verification page of its
own, `match.verification_url` links straight to it:

- **Curaçao** — the CGA certificate portal (`cert.cga.cw`), including
  `/token` cluster certificates listing every approved domain under
  the licence. A link stored on the CGA's legacy host `cert.gcb.cw` is served
  on `cert.cga.cw` (same path; the old host redirects there).
- **Tobique** — the TGC validation seal (`validate.thetgc.ca`), which
  lists the seal holder's main domain and the domain queried — not the
  licence's whole website cluster.

`null` for regulators with no such page (UKGC, MGA, KH, AN, IOM) and for
fuzzy matches. A nightly pass re-reads the stored verification pages
oldest-first (every Tobique seal each night; each Curaçao certificate
every 2–3 days) and updates `domain_status` only from a clean read of the regulator's
own words — so the URL is not just a citation, it's the mechanism that
keeps the row fresh.

**What the page itself says.** Next to the link, `match.verification_page_status`
is the licence status that page printed at our latest read of it, verbatim — a
CGA page's `Active` or `Revoked`, a Tobique seal's `VALID` — and
`match.verification_page_read_at` is when we read it (`null` for both while we
hold no reading). When the word is not one that says "in force", the page is
**not** confirmation of anything: say what it reads and when, and don't present
the link as proof. It moves no status by itself — the link's `domain_status`
comes from the register or the page's domain list, and a revocation only from a
regulator publication (`status_source_url`). With an in-force word, surface the
link next to the verdict: "verify on the regulator's own page" is the strongest
trust signal we can hand you.

## jurisdictions[] — dual-licensed domains

A brand can be licensed by **different legal entities in different
jurisdictions at the same time** (spinsup.com: Anjouan + Tobique;
me88.com: Anjouan + Curaçao). When the matched domain has more than
one (operator, jurisdiction) pair, the response carries a best-first
`jurisdictions[]` array — `jurisdictions[0]` is the pair `match`
reports — each entry with its own:

| Field | Meaning |
| --- | --- |
| `operator`, `operator_slug`, `jurisdiction` | The licensee and its regulator. |
| `license_number`, `license_reference_is_ours` | The licence number — or, when `license_reference_is_ours` is `true` (KH, TGC, IOM), our reference, never to be quoted as the regulator's. |
| `status`, `status_qualifier` | That licence's status, and the qualifier when `active` is not the whole truth (`provisional_under_assessment`). |
| `domain_status`, `domain_association` | This domain's listing under this operator (`active` / `delisted`), and `direct` / `white_label`. |
| `matched_domain` | The host this link is on: your host or its `www.` counterpart (the links of both spellings are one list). |
| `verification_url`, `verification_page_status`, `verification_page_read_at` | The regulator's own per-domain page for this link, where one exists, and what it printed about the licence at our latest read, verbatim, with when — [as on `match`](https://igregulator.io/docs/confidence/#verification_url--check-us-against-the-regulator). |
| `register` | `{ jurisdiction, last_read_at, fresh, sla_hours }` — when we last read the register behind this link, and whether that read is inside its freshness window (`null` when the link has no licence — see below). A secondary link can rest on a stale register while `match` rests on a fresh one. |

Per-link status matters: the same domain can be `active` under one
register and `delisted` under another — both true at once. Before
phrasing a single-jurisdiction verdict ("licensed in Anjouan"), check
whether the array is present and report the full picture. Absent for
ordinary single-licence domains.

### Which licence a domain link reports

A register links a domain to an **operator**, not to one of its licences, and an
operator can hold several in one jurisdiction (a UKGC licensee holds one per
activity). The licence on a link — on `match` and on every `jurisdictions[]`
entry — is the operator's best licence **that can cover a website**, `active`
first, then the most recently verified:

- **UKGC** — Remote or Ancillary Remote (the register's licence type); a
  Non-Remote licence is land-based and never covers a website.
- **MGA** — B2C only (`MGA/B2C/…`); B2B and corporate (CRP) licences never do.
- **Curaçao** — the licence the link's `cert.cga.cw` certificate names (the
  certificate id is the licence number's last segment); any of the operator's
  licences when it names none of them.
- **Anjouan, Kahnawake, Tobique, Isle of Man** — any of the operator's licences.

An operator that holds no such licence gives the link **no licence** — never a
licence that could not cover the site. The listing still stands: `match` names the
operator and the regulator (`jurisdiction`, `regulator_name`, `domain_status`,
`matched_domain`) with `license_number`, `license_id` and `status` null, and the
verdict is `licence_not_active` — "brc-uk.com is listed … by the UK Gambling
Commission under BRC Promotions Ltd, which holds no licence that can cover a website
(its UK Gambling Commission licences are non-remote only)". Never `not_found`: the
register does list the domain. And a site whose remote licence is suspended reads
`licence_not_active` too, even when the same company's betting shops hold an active
non-remote licence.

## Tiebreaking for equal similarity

Brand names like *Paddy Power* trigram-match several sister companies
(PPB Counterparty, PPB Entertainment, PPB GE, Power Leisure
Bookmakers) at similarity `1.0`. To keep the primary match **stable
across DB reindex and VACUUM**, `/v1/check` applies a documented
tiebreaker cascade whenever the top candidates are tied on similarity:

1. **similarity DESC** — closeness wins first, as ever.
2. **has_active DESC** — operators with at least one `active` licence
   are preferred over operators whose licences are all in a non-active
   state (expired, revoked, suspended, surrendered, or no longer listed
   in the register).
3. **oldest_active_issued ASC** — among active-licence candidates, the
   one whose *oldest* active licence issued first wins. Stability
   signal: a parent entity that has been licensed longest is the most
   useful "who actually runs this brand" answer.
4. **total_licenses DESC** — more licences across the register → more
   likely a parent entity rather than a single-purpose subsidiary.
5. **operator_slug ASC** — lexicographic final fallback. Always
   deterministic even when every previous rank is tied.

Clients that cache domain → operator mappings can rely on the primary
result remaining stable between index rebuilds; any change in primary
reflects a change in the underlying registry data, not PG query
randomness.

## alternatives[]

Up to 3 runner-up candidates, sorted by similarity descending.

- On `confidence: medium` or `low` (a fuzzy match), these are operators with the same or similar trading name that we ranked below the primary match.
- On a generic label (`match: null`, `match_absence_reason: generic_term`), this is always `[]` — we refuse to guess when the label is ambiguous.
- On `confidence: high` and on a `no_record_found` miss, also `[]`.

## Why the generic-label filter exists

Without it, `GET /v1/check?domain=casino.org` would return "Casino MK
Limited" with `confidence: medium` — deterministically, because "casino"
matches that trading name at similarity 1.0. But casino.org is on no
licence we read, and "Casino MK runs it" would be a guess dressed up as
an answer.

The blocklist is a substring regex over the normalised label —
`casino`, `poker`, `bingo`, `gambling`, `bet`, `slot`/`slots`,
`sportsbook`, `roulette`, `blackjack`, `wager`, `lottery`,
`gaming`. Matches anywhere in the label, so `casino.org`,
`bestcasino.com`, and `casino-bonus.com` all return `match: null`,
`confidence: low`, `match_absence_reason: generic_term` and empty
`alternatives[]`. Domains we read on a licence still resolve even when
they contain one of these keywords (`bet365.com`, `pokerstars.com`,
`casino.com`) because the domain-exact match runs before the generic
gate — and then, as always, `status` and `domain_status` say whether
that licence covers the site today. The related-hosts check runs before it
too: `sports.bet365.com` is `related_host_listed`, not `generic_term`.
