# Batch domain check

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

> Verify up to 100 domains in one call with POST /v1/check/batch — a KYB sweep or affiliate-list audit without N sequential requests, same verdict per row.

`POST /v1/check/batch` resolves up to **100 domains in one request**, so a
KYB sweep or affiliate-list audit is one round trip instead of N. 200
merchants = 2 calls, not 200.

Authenticated (the single `GET /v1/check` stays keyless); domains only.

## Request

```bash
curl -s -X POST https://api.igregulator.io/v1/check/batch \
  -H "Authorization: Bearer $IGREGULATOR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"domains":["bet365.com","www.virginbet.com","casino.org"]}'
```

## Response

```json
{
  "count": 3,
  "checked_jurisdictions": ["AN", "CW", "IOM", "KH", "MGA", "TGC", "UKGC"],
  "results": [
    {
      "query": { "domain": "bet365.com", "input": "bet365.com" },
      "verdict": "licensed",
      "verdict_detail": "bet365.com is listed, as of our read on 2026-09-30, on an active UK Gambling Commission licence held by Hillside (UK Gaming) ENC (licence 055149-R-331499-004); see GET /v1/check for this domain's 2 other operator links.",
      "match": { "operator": "Hillside (UK Gaming) ENC", "status": "active", "domain_status": "active", "...": "…" },
      "confidence": "high",
      "_meta": { "register": { "jurisdiction": "UKGC", "last_read_at": "2026-09-30T03:00:26.364Z", "fresh": true, "sla_hours": 24 } }
    },
    {
      "query": { "domain": "www.virginbet.com", "input": "www.virginbet.com" },
      "verdict": "licensed",
      "verdict_detail": "www.virginbet.com is listed, as of our read on 2026-09-30, on an active UK Gambling Commission licence held by Virgin Bet Limited (licence 054310-R-330640-007, white label).",
      "match": { "operator": "Virgin Bet Limited", "domain_association": "white_label", "...": "…" },
      "confidence": "high",
      "_meta": { "register": { "...": "…" } }
    },
    {
      "query": { "domain": "casino.org", "input": "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,
      "confidence": "low",
      "match_absence_reason": "generic_term"
    }
  ],
  "_meta": {
    "checked_at": "2026-09-30T18:48:03.561Z",
    "stale_jurisdictions": [{ "code": "TGC", "last_read_at": "2026-09-21T04:15:08.476Z" }]
  }
}
```

Said once, at the top, because it is the same for every row:
`checked_jurisdictions` (the registers every row was checked against) and
`_meta` — `checked_at`, and `stale_jurisdictions`, the covered registers past
their freshness window, which weaken every "not found" in the batch.

### What a row carries — and what it leaves out

A row is resolved exactly like `GET /v1/check` (same matching, same verdict
rules), but it is not the whole single-check response. Every row that is not an
error carries:

- `query` — `domain`, the hostname we looked up, and `input`, the string you
  sent ([hostnames](https://igregulator.io/docs/batch/#hostnames)).
- `verdict` and `verdict_detail` — branch on the first, quote the second
  ([confidence scoring](https://igregulator.io/docs/confidence/#verdict--the-answer-in-one-field)).
- `match` — the same object as on `GET /v1/check`, or `null`.
- `confidence` — always present; `none` on a miss.
- `match_absence_reason` — only when `match` is `null`.
- `related_hosts[]` — only when your host (and its `www.` counterpart) isn't
  stored but other hosts on its registrable domain are
  ([related hosts](https://igregulator.io/docs/confidence/#related_hosts--hosts-a-register-does-list)).
- `_meta.register` — when there is a matched jurisdiction: when we last read its
  register and whether that read is inside its freshness window.

A row does **not** carry:

- `jurisdictions[]` — a dual-licensed domain's other operator links.
  `verdict_detail` says how many there are; `GET /v1/check` on that domain lists
  them.
- `as_of` — the batch takes no `as_of`. Ask `GET /v1/check?as_of=` per domain.
- `alternatives[]`, a per-row `checked_jurisdictions` (it is at the top), or the
  rest of the single check's `_meta` (`source_url`, `scraped_at`, …).

## Partial success

One bad entry doesn't fail the batch. An entry we can't look up — not a string,
longer than 253 characters, or not a hostname even after
[normalising](https://igregulator.io/docs/batch/#hostnames) — comes back in its place as an error row, and every
other domain still resolves:

```json
{ "query": { "input": "https://bet365.com/" }, "match": null, "confidence": "none", "error": "invalid_hostname", "error_detail": "Not a hostname: pass a bare hostname — no scheme, path, port or underscores." }
```

`error` is `invalid_hostname` for a string that isn't a hostname (or is longer than
253 characters) and `invalid_input` for an entry that isn't a string at all;
`error_detail` says which. `query` carries only `input` — what you sent — since
there is no hostname to report.

An error row has **no `verdict`** and no `verdict_detail`: we did not check it,
so there is nothing to say about it — report it as unchecked, never as not found.

The whole request is refused (`400 invalid_query`) only when the body itself is
wrong: not JSON, no `domains` array, an empty array, or more than 100 entries.

## Hostnames

Each entry is normalised before it is looked up, and `query.domain` is the
result:

- lowercased, surrounding whitespace trimmed;
- a trailing dot dropped (`bet365.com.` → `bet365.com`);
- an internationalised (Unicode) name converted to punycode, the form registers
  list (`bücher.example` → `xn--bcher-kva.example`).

`query.input` is what you sent, as you sent it, so you can join a row back to your
own list. A URL (`https://bet365.com/`) is not a hostname — strip the scheme and
path yourself. A host and its `www.` counterpart are the same site; any other
subdomain is a different host
([which host answers](https://igregulator.io/docs/confidence/#which-host-answers--exact-www-then-the-rest-of-the-domain)).

## Limits & semantics

- **`POST` only.** `GET /v1/check/batch` (or any other method) answers
  `405 method_not_allowed` with `Allow: POST`, key or no key; for one domain
  without a key, use `GET /v1/check?domain=`.
- **Max 100 domains** per request; paginate beyond.
- Domains are resolved with bounded concurrency server-side — order of
  `results` follows the order you sent.
- Counts as **one request** against your plan quota today.
- Each result uses the same matching as `GET /v1/check`: the host and its `www.`
  counterpart (one site), then other hosts on the registrable domain (never taken
  as your host), then a name match.

## Clients

Branch on `verdict` — only `licensed` and `licensed_provisional` mean licensed
now — and keep `verdict_detail` with the result:

```python
import requests

r = requests.post(
    "https://api.igregulator.io/v1/check/batch",
    headers={"Authorization": f"Bearer {KEY}"},
    json={"domains": domains[:100]},
)
r.raise_for_status()
for row in r.json()["results"]:
    sent = row["query"].get("input", row["query"]["domain"])
    if row.get("error"):
        # Not checked at all — no verdict. Fix the entry and send it again.
        print(sent, "→ not checked:", row["error"])
    elif row["verdict"] == "licensed":
        print(sent, "→ licensed:", row["verdict_detail"])
    elif row["verdict"] == "licensed_provisional":
        # In force, provisionally: accept per your policy, and recheck later.
        print(sent, "→ licensed, provisionally (recheck):", row["verdict_detail"])
    else:
        # Not confirmed as licensed by the registers we cover. Not "unlicensed":
        # review it, with the sentence.
        print(sent, f"→ review ({row['verdict']}):", row["verdict_detail"])
```

```javascript
const res = await fetch('https://api.igregulator.io/v1/check/batch', {
  method: 'POST',
  headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({ domains: domains.slice(0, 100) }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const { results } = await res.json();
for (const row of results) {
  const sent = row.query.input ?? row.query.domain;
  if (row.error) {
    // Not checked at all — no verdict. Fix the entry and send it again.
    console.log(sent, '→ not checked:', row.error);
  } else if (row.verdict === 'licensed' || row.verdict === 'licensed_provisional') {
    // licensed_provisional: in force, provisionally — recheck later.
    console.log(sent, `→ ${row.verdict}:`, row.verdict_detail);
  } else {
    // Not confirmed as licensed by the registers we cover — review, never "unlicensed".
    console.log(sent, `→ review (${row.verdict}):`, row.verdict_detail);
  }
}
```
