How to Check Domain Availability and Registration Price With Codex (Full SKILL.md Included)

Key takeaways

A Codex skill that splits one domain question into three: free registry state from RDAP, free Cloudflare prices from a public socket, and the token-gated check.

The complete skill is the two blocks below: SKILL.md and domain_check.py, both printed in full and unedited, each labelled with the path to save it to. Make the directory, copy the two files into it, and you are done:

Code
mkdir -p ~/.codex/skills/codex-seo-domain-check/scripts

Everything after those two blocks is the explanation - what they do, how to read the output, and the four places a domain check goes wrong. Read it whenever you like; the files do not depend on it. If you would rather not route them by hand, the single paste at the end of that first section writes both files for you.

The complete skill: SKILL.md

Save this block to ~/.codex/skills/codex-seo-domain-check/SKILL.md. It is the file in full, byte for byte, frontmatter included.

markdown
---
name: codex-seo-domain-check
description: Use when the user asks whether a domain is available, taken, expiring, or aged; asks what a domain costs to register or renew; asks to price a shortlist of candidate names; or asks to vet a domain before buying it, transferring it, or paying for a link on it. Splits one question into three - registry state and Cloudflare's price both come free and need no account, and only the pre-registration check needs a token.
---
# Domain Registration and Price Check

Three different questions get asked as one, and the confusion costs money.

*Is this name taken?* That is a registry fact. IANA publishes the RDAP
bootstrap file that maps every TLD to its registry's RDAP server, and asking
that server costs nothing and needs no account.

*What does it cost?* That is a quote, not a fact. Cloudflare publishes one from
its public domain search, which hydrates from a websocket at
`wss://search.registrar.cloudflare.com/v1/ws`. That socket has no login, no
token, and no account behind it - the tenant is literally named
`public_registrar`. It gates on an `Origin` header, which is a formality rather
than a credential.

The one thing that *does* need an account is the pre-registration check:
`POST /accounts/{id}/registrar/domain-check`, which needs an API token, a
billing profile, and a default registrant contact. That is the only tier that
tells you whether Cloudflare will actually sell you the name.

Never answer with a memorized number. Registry fees move on announced dates -
Verisign raises the .com wholesale fee on 2026-11-01 - and any price table you
bake into a skill is wrong within a year.

## Credential tiers

| Tier | What you have | What unlocks |
|------|---------------|--------------|
| 0 | Nothing but python3 | registry state, registration and expiry dates, registrar, nameservers, DNSSEC, TLD coverage |
| 1 | Nothing but python3 | Cloudflare's own price catalog: `registration`, `renewal`, `currency`, TAKEN/AVAILABLE, premium flag |
| 2 | `CLOUDFLARE_API_TOKEN` + `CLOUDFLARE_ACCOUNT_ID` | `registrable` yes/no, API tier, and a `reason` code when it says no |

Tiers 0 and 1 are both free and both need no account. The difference is what
they answer: Tier 0 is the registry's view of whether the name exists, Tier 1 is
Cloudflare's view of what it costs. They can disagree, and when they do, the
disagreement is the interesting part - a name with no registry record and a
price is a name you can probably buy.

State the tier you used before presenting results. A Tier 0 run must not be
summarized as "cheap" or "available" - only as "no registry record".

## Commands

```
python3 domain_check.py example.com                          # tier 0
python3 domain_check.py example.co.uk example.dev --json
python3 domain_check.py --file candidates.txt                # one name per line
python3 domain_check.py --file candidates.txt --public       # tier 0 then tier 1
python3 domain_check.py --file candidates.txt --public --cloudflare  # all three
python3 domain_check.py --tlds                               # which TLDs have RDAP
python3 domain_check.py --bootstrap-file /tmp/rdap.json example.com
```

- `--file` accepts `#` comments and dedupes. Run the free passes over the whole
  candidate list first, then re-run only the survivors with `--cloudflare`.
- `--public` prices every name Tier 0 did not already prove registered,
  including the `NO_RDAP` ones - .co.uk and .co have no RDAP answer but do have
  a price.
- `--cloudflare` automatically skips names that Tier 0 already proved are
  registered. You are not billed in money, but there is no reason to spend a
  metered call proving a taken name is taken.
- `--json` emits `{accessed, tier0, tier1_public, tier1_error, tier2_api,
  tier2_error}` for downstream scripts.

## Tier 0: what a registry record actually says

Bootstrap: `https://data.iana.org/rdap/dns.json` - 1,200 TLDs across 590
registry services at the time of writing. The script caches it for 24 hours
under `~/.cache/domain-check/`.

Query shape: `<registry-server>/domain/<fqdn>` with
`Accept: application/rdap+json`.

| Field | Where it comes from | Why it matters |
|-------|--------------------|----------------|
| Registration date | `events[eventAction=registration]` | domain age, for due diligence |
| Expiration date | `events[eventAction=expiration]` | expiry risk on a name you are about to depend on |
| Status codes | `status[]` | `clientHold` / `serverHold` / `pendingDelete` block transfers |
| Registrar | `entities[roles=registrar]` vCard `fn` | who actually holds it |
| Nameservers | `nameservers[].ldhName` | `.ns.cloudflare.com` means it is already on Cloudflare DNS |

Status codes are where most readers stop reading too early. A name can answer
200 with a full record and still be unusable: `clientHold` and `serverHold`
take it out of the zone, `redemptionPeriod` and `pendingDelete` mean the
current owner already lost it, and `clientTransferProhibited` means your
transfer request will be refused. The script flags all of these.

## The gate that keeps you honest

**An RDAP 404 is not "available".** It means the registry has no record for
that string right now. It does not mean you can buy it:

- Reserved and premium names can return 404 and still be unsellable, or
  sellable only at a premium the script never sees.
- A ccTLD can have no RDAP service registered at IANA at all - the script
  reports `NO_RDAP` rather than guessing.
- Some registries answer 200 with an empty object for names they do not hold.
  The script treats "200 with no events and no nameservers" as `NO_RECORD`.
- Registry state can change between your check and your purchase. That is
  exactly why Cloudflare's own docs say to run Check immediately before
  registering.

Report Tier 0 results as `NO_RECORD`, never as "available". `NO_RECORD` is a
fact you measured; "available" is a claim you cannot support from it.

## Tier 1: the free price, from the public socket

The public search page is an Astro shell. Fetching it with curl returns 200 and
about a kilobyte of visible text, because the prices hydrate on the client. The
client is a websocket, and the socket is public.

```
wss://search.registrar.cloudflare.com/v1/ws?session_id=<any uuid>
Origin: https://www.cloudflare.com
```

Drop the `Origin` header and the handshake comes back `403 Forbidden`. Send it
and you get `101 Switching Protocols`. That is the whole gate.

There is one way to get that same `403` while doing everything right: the edge
also refuses the handshake when the client's TLS stack is **LibreSSL**, which is
what macOS's system Python links. Both causes produce an identical, hint-free
`403`, so check the interpreter before you go hunting for a missing header.
Run Tier 1 on a Python built against OpenSSL 3.x - Homebrew's `python3.11` is
one - and the script names the cause in its error when it detects it.

The protocol is four messages deep:

1. Server sends `session.connected` with `tenantId: "public_registrar"`. Wait
   for it before sending anything.
2. Send `{"type":"search.start","searchId":1,"query":"<label>","profile":
   "full_scan"}`. **`query` is the second-level label only** - `example`, not
   `example.com`. A full domain here comes back as a protocol error.
3. Server replies `search.started` with `totalCandidates`, then
   `results.snapshot` carrying every candidate row.
4. Every row in the snapshot says `status: "unknown"`. Real statuses arrive
   afterwards as `results.delta` patches.

Each row is `{id, fqdn, tld, status, precedence, pricing{registration, renewal,
currency}, premium, hints[]}`. `status` resolves to `taken` or `available`, and
`totalCandidates` runs into the hundreds, so one query prices a name across
every extension Cloudflare carries.

**`precedence` is a resolution ladder, and stopping at the wrong rung quotes the
wrong price.** Traced live for one `.dev` name:

```
precedence 0   snapshot   status unknown   $12.20 / $12.20
precedence 1   patch      status taken     $12.20 / $12.20
precedence 2   patch      status taken     $0.20 / $329.20   premium, hint
                                            "non_standard_renewal_fee"
```

The rung-1 answer is a plausible, well-formed, completely wrong price. It says
the renewal is $12.20 when the catalog's final word is $329.20. So "status is no
longer `unknown`" is not the finish line.

The ladder has **two terminal branches, and neither is a ceiling**:

| Outcome | Commits at | Observed |
|---------|-----------|----------|
| taken, premium, or otherwise unusual | rung 2 | `example.dev` $0.20 / $329.20 premium; `example.co` $500 / $500 premium |
| available at a normal price | rung 3 | `geo-answer-lab.com` $10.46 / $10.46, `premium` flipping `null` → `false` on the way |
| never resolves | stays at rung 1 | `answerwatch.com` taken, still rung 1 after a full window |

Two consequences worth building around:

- **Do not hardcode a stop at rung 2.** It is the terminal rung for one branch
  only. The page's own client treats the order as open-ended and monotonic (its
  merge drops any patch whose `precedence` is lower than the row's current one),
  so a rung-3 answer arriving after a rung-2 patch is a legitimate override.
  Wait for the row to stop changing instead.
- **Do not let a late `results.snapshot` overwrite a resolved row.** Snapshots
  carry `precedence: 0` for every candidate and they arrive repeatedly. Merging
  one blindly un-resolves a row that a delta already answered, which is what
  made the same name look like it was quoting two different prices, and what
  made a perfectly resolvable row report as provisional. The script applies the
  client's own rule: a lower rung never overwrites a higher one.

That second failure is not theoretical. The same `.co` name returned `$500,
premium` on one run and `$15, not premium` on another, from the same code,
minutes apart. The script prints a provisional-value warning when a row never
commits; believe it, and re-run rather than quoting the lower number.

`hints` is where the catalog explains itself. `non_standard_renewal_fee` is the
one to look for: it appears exactly when the headline registration price and the
real renewal have come apart, which is the case that makes a cheap-looking name
expensive to keep.

The same page also loads `https://domains.cloudflare.com/api/tlds`, which is
public JSON with no auth and is the supported-extension list Cloudflare's own
docs still describe as not existing yet.

## Tier 2: the quote the registry signs off on

```
curl --request POST \
  --url "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/registrar/domain-check" \
  --header "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
  --header "Content-Type: application/json" \
  --data '{"domains": ["acmecorp.dev"]}'
```

- Up to **20 domains per request**. The script chunks for you.
- `registrable: true` carries a `pricing` object: `currency`,
  `registration_cost`, `renewal_cost`, all **strings** so decimals survive.
  Do not parse them as floats before quoting them.
- `registrable: false` carries `reason`. Read the reason, not the boolean:

| Reason | What it means |
|--------|---------------|
| `domain_unavailable` | genuinely taken |
| `extension_not_supported_via_api` | works in the dashboard, not in the API beta |
| `extension_not_supported` | Cloudflare does not carry this TLD |
| `extension_disallows_registration` | registry does not permit registration |

Collapsing all four into "not available" is the most common error in this
workflow. Two of the four mean "try the dashboard instead".

`GET /registrar/domain-search?q=` also returns pricing, but the docs call it
"intended for discovery", "cached", and explicitly "not the source of truth".
Use it for naming ideas, or use Tier 1, which reads the same catalog without
needing an account. Use `domain-check` for anything you will act on.

Tier 1 and Tier 2 answer different questions, so when they disagree, neither is
lying. Tier 1 quotes the catalog; Tier 2 asks the registry. A name can carry a
Tier 1 price and still come back `registrable: false` with
`extension_not_supported_via_api`, which means the catalog will quote it and
the API will not sell it.

## Token setup that actually works

`domain-check` is a write-scoped endpoint. A read-only token gets a 403 that
looks like a permissions bug but is not. The account needs, before the call
succeeds:

1. An API token with **Registrar write** permission, scoped to the account.
2. A billing profile with a valid default payment method.
3. A default registrant contact.
4. The Domain Registration Agreement accepted.

Export both values in the shell that runs the script:

```
export CLOUDFLARE_API_TOKEN="..."
export CLOUDFLARE_ACCOUNT_ID="..."
```

Never write either into the skill directory or into a script. They are
account-scoped and the token can spend money.

## Beta limits, as of 2026-09

State these before quoting availability, because they change the answer:

- Only a subset of Cloudflare Registrar's 430+ extensions are reachable
  through the API beta. Cloudflare has not published the list.
- Premium registrations are not supported through the API.
- Renewals, transfers, and contact updates are **not yet available** through
  the API. Registration is the only write operation.
- Registration returns `201` if it finishes within 10 seconds, `202` if the
  workflow is still running. Poll
  `GET /registrar/registrations/{domain}/registration-status` and stop on
  `action_required` or `failed`; do not retry a `202`.
- Cloudflare does not mark domain prices up at all - the product page states
  registration, transfer, and renewal prices are "at or below what registries
  and ICANN charge us". That is a pricing *model*, not a price. Quote the
  number `domain-check` returned, never the model.

## Errors

| Scenario | Action |
|----------|--------|
| No bootstrap, no network | pass `--bootstrap-file` with a local copy |
| TLD missing from bootstrap | report `NO_RDAP`; do not fall back to guessing |
| RDAP 404 | report `NO_RECORD`; say the registry has no record, not "available" |
| RDAP 429 or 5xx | the script retries once, then reports `UNKNOWN` |
| `403` on the Tier 1 websocket | one of three causes: a dropped `Origin` header, rate limiting (see below), or a LibreSSL interpreter. Check `python3 -c "import ssl; print(ssl.OPENSSL_VERSION)"` first - if it says LibreSSL, switch interpreter |
| Tier 1 returns `NOT_IN_CATALOG` | Cloudflare does not carry that extension; not the same as taken |
| Tier 1 `status` still `unknown` after the window | the catalog did not resolve it; say so, do not infer |
| Tier 1 row reaches `precedence: 2` | committed for a taken/premium name; for an available name, rung 3 is still coming - do not stop reading |
| `401` / `403` on domain-check | token lacks Registrar write permission |
| `extension_not_supported_via_api` | try the Cloudflare dashboard before writing the name off |
| Prices differ from yesterday | expected; a registry fee moved. Re-quote, do not average |
| Asked for a price with no token | run Tier 1; it is free. Only say "unavailable" if Tier 1 also fails |

**Tier 1 rate-limits.** Probe it repeatedly and the handshake starts failing
before it even completes TLS, which reads like a network error and is not one.
The client in the bundle carries `rateLimit`, `softLimit: "Reduced results
mode"`, and a `deferReconnectUntil` timer, so this is designed behaviour rather
than an accident of your IP. Space the queries out, cache per label within a
run, and treat a sudden handshake failure as "wait", not "the endpoint moved".

## Output contract

Produce `DOMAIN-CHECK-{date}.md` with:

1. Tiers used, and the date of the run.
2. A state table: domain, state, registration date, expiry, nameserver.
3. The flag list - expiry within 45 days, status codes that block transfer,
   domains already on Cloudflare nameservers, DNSSEC unsigned.
4. Registered names removed from the pricing pass, listed separately.
5. Tier 1 rows for every candidate Tier 0 did not prove registered, each with
   the currency, both costs, and the premium flag.
6. Tier 2 rows only for names that returned `registrable: true`, or a `reason`
   code when they did not.
7. A closing line naming what could not be assessed: TLDs with no RDAP,
   extensions outside the catalog, extensions the API will not sell.

The complete script: scripts/domain_check.py

Save this block to ~/.codex/skills/codex-seo-domain-check/scripts/domain_check.py. Standard library only, Python 3.8 or newer, nothing to install first.

python
#!/usr/bin/env python3
"""domain_check.py - registry facts first, price quote second.

Three tiers, deliberately separated:

  Tier 0  no account, no key. IANA publishes the RDAP bootstrap file that maps
          every TLD to its registry's RDAP server. Ask that server and you get
          the registry's own record: registration date, expiry, status codes,
          registrar, nameservers. Free, unmetered, and authoritative for
          *state*.

  Tier 1  still no account, no key. The public search page at
          www.cloudflare.com/domains/search is an Astro shell that hydrates from
          a public websocket, wss://search.registrar.cloudflare.com/v1/ws. That
          socket answers with Cloudflare's own price catalog: registration,
          renewal, currency, taken/available, and whether the name is premium.
          It gates on an Origin header, not on a credential, so a script reaches
          it by sending `Origin: https://www.cloudflare.com`.

  Tier 2  Cloudflare API token with Registrar write permission. Calls
          POST /accounts/{id}/registrar/domain-check, which queries the registry
          directly and returns registrable + pricing + a reason code. This is
          the only tier that tells you whether the API will actually *sell* you
          the name, and the only one that needs an account.

Tier 0 tells you whether a name is worth pricing. Tier 1 prices it for free.
Tier 2 is the pre-registration check, and is the one to run the day you buy.

Stdlib only. Python 3.8+.
"""

import argparse
import base64
import json
import os
import re
import socket
import ssl
import struct
import sys
import time
import urllib.error
import urllib.request
import uuid

BOOTSTRAP_URL = "https://data.iana.org/rdap/dns.json"
BOOTSTRAP_TTL = 86400  # seconds; IANA updates this rarely
CACHE_PATH = os.path.join(
    os.environ.get("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")),
    "domain-check",
    "rdap-bootstrap.json",
)
UA = "codex-seo-domain-check/1.1 (+registry state lookup via RDAP)"
CF_API = "https://api.cloudflare.com/client/v4"
CF_CHECK_MAX = 20  # documented cap for domain-check

# Tier 1: Cloudflare's public registrar search socket. The Origin header is the
# whole gate - drop it and the handshake comes back 403. It is not a
# credential: the tenant on the other side is literally named "public_registrar".
WS_HOST = "search.registrar.cloudflare.com"
WS_PATH = "/v1/ws"
WS_ORIGIN = "https://www.cloudflare.com"
WS_UA = (
    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
    "(KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36"
)
WS_SETTLE = 7.0  # seconds to keep reading after search.start; statuses stream in

# Cloudflare's edge refuses the handshake from the LibreSSL that ships with
# macOS system Python, before a single byte of the request is read. The only
# symptom is a bare `403` on a socket that carries no credential to get wrong,
# which reads like the Origin header is missing or the client is rate-limited -
# both wrong. The stack is the cause, so the message says so once.
_TLS_HINT_SHOWN = False


def _tls_refusal_hint():
    """Explain a refusal once, when this interpreter's TLS stack is the cause."""
    global _TLS_HINT_SHOWN
    if _TLS_HINT_SHOWN or "LibreSSL" not in ssl.OPENSSL_VERSION:
        return ""
    _TLS_HINT_SHOWN = True
    return (
        " [this interpreter links %s, which Cloudflare's edge refuses before"
        " reading the request. Re-run with a Python built against OpenSSL 3.x -"
        " e.g. the Homebrew python3.11. The Origin header and rate limiting are"
        " not the cause.]" % ssl.OPENSSL_VERSION
    )

# The `precedence` ladder the catalog resolves each row through. 0 is the
# snapshot placeholder, 1 is the preliminary answer, and >= 2 is where the
# catalog has committed. There is no documented ceiling: rung 3 was observed
# on available .com names, and the page's own client treats the ladder as an
# open-ended monotonic order, so a later higher rung always wins.
AUTHORITATIVE_PRECEDENCE = 2  # lowest rung treated as a committed answer
QUIET_WINDOW = 1.5  # seconds of no change before a committed row is settled


# Registry status codes that mean "this name is not going to be sold to you by
# walking up to the register button". Not exhaustive; the point is to stop a
# reader from reading "registered" and stopping there.
BLOCKING_STATUS = {
    "clienthold",
    "serverhold",
    "pendingdelete",
    "redemptionperiod",
    "pendingtransfer",
    "clienttransferprohibited",
}
EXPIRING_DAYS = 45

CF_REASON_HELP = {
    "domain_unavailable": "registry says the name is taken",
    "extension_not_supported_via_api": "TLD works in the Cloudflare dashboard but not through the API beta",
    "extension_not_supported": "Cloudflare does not offer this TLD at all",
    "extension_disallows_registration": "TLD exists but the registry does not allow registration",
}


# --------------------------------------------------------------------------
# transport
# --------------------------------------------------------------------------

def _ssl_context():
    ctx = ssl.create_default_context()
    return ctx


def fetch(url, headers=None, data=None, method=None, retries=1, timeout=25):
    """Return (status, body_bytes, error_string). Never raises for HTTP errors.

    A single retry absorbs the intermittent TLS EOFs that registries throw at
    scripted clients; beyond that we report UNKNOWN rather than guess.
    """
    hdrs = {"User-Agent": UA, "Accept": "application/rdap+json, application/json"}
    if headers:
        hdrs.update(headers)
    attempt = 0
    last = "no attempt made"
    while attempt <= retries:
        attempt += 1
        req = urllib.request.Request(url, data=data, headers=hdrs, method=method)
        try:
            with urllib.request.urlopen(req, timeout=timeout, context=_ssl_context()) as r:
                return r.status, r.read(), None
        except urllib.error.HTTPError as e:
            body = b""
            try:
                body = e.read()
            except Exception:
                pass
            # 404 is an answer, not a failure. Do not retry it.
            if e.code in (400, 401, 403, 404, 422):
                return e.code, body, None
            last = "HTTP %s" % e.code
        except urllib.error.URLError as e:
            last = "URLError: %s" % e.reason
        except (ssl.SSLError, OSError) as e:
            last = "%s: %s" % (type(e).__name__, e)
        except Exception as e:  # noqa: BLE001 - report, never crash mid-batch
            last = "%s: %s" % (type(e).__name__, e)
        if attempt <= retries:
            time.sleep(1.5)
    return None, b"", last


# --------------------------------------------------------------------------
# tier 1 - Cloudflare's public registrar search socket
# --------------------------------------------------------------------------
#
# There is no websocket client in the standard library, so this is the smallest
# RFC 6455 client that gets the job done: a client handshake, masked text
# frames out, unmasked text frames in. Pings, pongs and close frames are read
# and discarded because the server sends them and we do not need to answer to
# complete a single search.

class _WS(object):
    def __init__(self, sock, initial=b""):
        self.sock = sock
        self.buf = initial

    def _need(self, n):
        while len(self.buf) < n:
            chunk = self.sock.recv(65536)
            if not chunk:
                raise IOError("socket closed mid-frame")
            self.buf += chunk

    def send_json(self, obj):
        payload = json.dumps(obj).encode("utf-8")
        mask = os.urandom(4)
        n = len(payload)
        if n < 126:
            head = bytes(bytearray([0x81, 0x80 | n]))
        elif n < 65536:
            head = bytes(bytearray([0x81, 0xFE])) + struct.pack("!H", n)
        else:
            head = bytes(bytearray([0x81, 0xFF])) + struct.pack("!Q", n)
        masked = bytes(bytearray(b ^ mask[i % 4] for i, b in enumerate(payload)))
        self.sock.sendall(head + mask + masked)

    def recv_json(self):
        """Return the next text frame as an object, or None for anything else."""
        while True:
            self._need(2)
            opcode = self.buf[0] & 0x0F
            n = self.buf[1] & 0x7F
            off = 2
            if n == 126:
                self._need(4)
                n = struct.unpack("!H", self.buf[2:4])[0]
                off = 4
            elif n == 127:
                self._need(10)
                n = struct.unpack("!Q", self.buf[2:10])[0]
                off = 10
            self._need(off + n)
            body = self.buf[off : off + n]
            self.buf = self.buf[off + n :]
            if opcode == 0x1:
                try:
                    return json.loads(body.decode("utf-8"))
                except (ValueError, UnicodeDecodeError):
                    return None
            if opcode == 0x8:
                raise IOError("server closed the connection")


def ws_connect(timeout=20):
    """Open the public search socket. Returns a _WS, or raises."""
    path = "%s?session_id=%s" % (WS_PATH, uuid.uuid4())
    raw = socket.create_connection((WS_HOST, 443), timeout=timeout)
    sock = ssl.create_default_context().wrap_socket(raw, server_hostname=WS_HOST)
    sock.settimeout(timeout)
    key = base64.b64encode(os.urandom(16)).decode("ascii")
    request = (
        "GET %s HTTP/1.1\r\n"
        "Host: %s\r\n"
        "Upgrade: websocket\r\n"
        "Connection: Upgrade\r\n"
        "Sec-WebSocket-Key: %s\r\n"
        "Sec-WebSocket-Version: 13\r\n"
        "Origin: %s\r\n"
        "User-Agent: %s\r\n"
        "\r\n"
    ) % (path, WS_HOST, key, WS_ORIGIN, WS_UA)
    sock.sendall(request.encode("utf-8"))
    buf = b""
    while b"\r\n\r\n" not in buf:
        chunk = sock.recv(4096)
        if not chunk:
            raise IOError("connection closed during the websocket handshake")
        buf += chunk
    head, _, rest = buf.partition(b"\r\n\r\n")
    status_line = head.split(b"\r\n")[0].decode("latin1", "replace")
    if " 101 " not in status_line:
        raise IOError("handshake refused: %s%s" % (status_line, _tls_refusal_hint()))
    return _WS(sock, rest)


def _apply(items, key, incoming):
    """Merge one incoming row, using the same rule as the page's own client.

    In the shipped client (`DomainSearchApp`, functions `v0`/`yt`), an incoming
    patch is compared against the row it would replace:

        v0 = (existing, incoming) => (incoming ?? 0) < existing

    and a patch that is "stale" by that test has its `status` and `precedence`
    dropped rather than applied. So `precedence` is a monotonic authority
    ladder: a lower rung never overwrites a higher one.

    That matters here because `results.snapshot` and `results.delta` both
    arrive repeatedly, and a snapshot carries `precedence: 0` for every row.
    Applying one blindly un-resolves a row that a delta already answered, which
    is how the same name appeared to quote two different prices minutes apart.
    """
    prev = items.get(key)
    if prev is not None:
        p_new = incoming.get("precedence")
        p_old = prev.get("precedence")
        if p_new is not None and p_old is not None and p_new < p_old:
            return
    merged = dict(prev or {})
    merged.update(incoming)
    items[key] = merged


def _read_until_settled(ws, items, want, settle):
    """Read frames until `want` stops changing. Returns nothing; mutates items.

    The answer arrives in stages, and the stages are not equivalent. Observed
    for example.dev:

        precedence 0  snapshot, status unknown, $12.20 / $12.20
        precedence 1  status taken,        $12.20 / $12.20
        precedence 2  status taken,        $0.20 / $329.20  premium, hint
                      "non_standard_renewal_fee"

    So "status went non-unknown" is NOT the finish line - it is the preliminary
    answer, and stopping there quotes a standard price for a name whose real
    renewal is sixteen times higher. Wait for the row to stop changing instead.

    Note what is deliberately NOT done here: returning the moment precedence
    reaches 2. Rung 2 is not a documented ceiling. An available .com name was
    observed resolving 1 -> 3 (premium flipping from null to false on the way),
    and the client treats the ladder as open-ended and monotonic, so a rung-3
    refinement arriving after a rung-2 patch is a legitimate override. Stopping
    at a hardcoded rung is the same early-stop mistake one rung higher, so the
    stop condition is instead: authoritative rung reached AND row gone quiet.
    """
    def row_key():
        row = items.get(want)
        if not row:
            return None
        return (
            row.get("status"),
            row.get("precedence"),
            json.dumps(row.get("pricing"), sort_keys=True),
            bool(row.get("premium")),
        )

    # A short read timeout turns a quiet socket into a clock check rather than
    # a blocking wait, so the quiet window below can actually fire.
    ws.sock.settimeout(2.0)
    stop = time.time() + settle
    quiet_until = None
    last_key = None
    while time.time() < stop:
        if quiet_until is not None and time.time() >= quiet_until:
            return
        try:
            msg = ws.recv_json()
        except socket.timeout:
            continue
        except (ssl.SSLError, OSError):
            return
        if not msg:
            continue
        t = msg.get("type")
        if t == "results.snapshot":
            for it in msg.get("items") or []:
                k = (it.get("fqdn") or it.get("id") or "").lower()
                if k:
                    _apply(items, k, it)
        elif t == "results.delta":
            for p in msg.get("patches") or []:
                k = (p.get("id") or "").lower()
                if k:
                    _apply(items, k, p)
        elif t == "error":
            return
        key = row_key()
        if key is not None and key != last_key:
            last_key = key
            # The quiet clock starts only once the row carries an authoritative
            # rung. Before that, a lull means the answer has not arrived yet
            # rather than that it has finished arriving.
            if (key[1] or 0) >= AUTHORITATIVE_PRECEDENCE:
                quiet_until = time.time() + QUIET_WINDOW


def public_search(label, tlds, timeout=25, settle=WS_SETTLE):
    """Price one label across several extensions on a single connection.

    Returns ({fqdn: item}, None). Results are keyed by full domain, because the
    catalog carries compound ids like example.com.co that no suffix guess would
    reconstruct.

    One connection carries every search. That is not just an optimisation: the
    socket is unauthenticated and rate-limits, and opening a connection per
    domain is what gets the handshake refused. The page's own client keeps one
    socket and increments searchId, so this does the same.
    """
    ws = None
    try:
        ws = ws_connect(timeout=timeout)
    except Exception as e:  # noqa: BLE001 - network shape varies; report it
        return None, "%s: %s" % (type(e).__name__, e)
    items = {}
    try:
        # The server opens with session.connected. It is the readiness signal.
        deadline = time.time() + timeout
        for _ in range(200):
            if time.time() > deadline:
                return None, "no session.connected before the deadline"
            msg = ws.recv_json()
            if msg and msg.get("type") == "session.connected":
                break
            if msg and msg.get("type") == "error":
                return None, "server refused the session: %s" % json.dumps(msg)[:200]

        for i, tld in enumerate(tlds, start=1):
            want = "%s.%s" % (label, tld)
            # query takes the second-level label only; a full domain here comes
            # back as a protocol error. tldSelector is what tells the server
            # which extension to resolve.
            ws.send_json({
                "type": "search.start",
                "searchId": i,
                "query": label,
                "tldSelector": tld,
                "profile": "full_scan",
                "prefetchMax": 250,
                "sort": "price",
                "sortDir": "asc",
            })
            _read_until_settled(ws, items, want, settle)
    except Exception as e:  # noqa: BLE001
        if not items:
            return None, "%s: %s" % (type(e).__name__, e)
    finally:
        try:
            ws.sock.close()
        except Exception:  # noqa: BLE001
            pass

    if not items:
        return None, "no results came back for %r" % label
    return items, None


def split_label(domain):
    """Return (leftmost label, last label). The last label is the one to send
    as tldSelector; the leftmost is what query accepts. Anything in between -
    the com in example.com.co - is matched on the returned fqdn instead of
    being guessed at, because where the public suffix ends is exactly the
    question this script refuses to guess about."""
    labels = domain.lower().strip(".").split(".")
    if len(labels) < 2:
        return None, None
    return labels[0], labels[-1]


def _tier1_row(domain, it, err):
    if err:
        return {"domain": domain, "tier": 1, "state": "UNKNOWN",
                "note": err, "quote": None}
    if not it:
        return {"domain": domain, "tier": 1, "state": "NOT_IN_CATALOG",
                "note": "Cloudflare's public catalog returned no row for %s "
                        "(the extension is probably not carried)" % domain,
                "quote": None}
    status = it.get("status") or "unknown"
    state = {"taken": "TAKEN", "available": "AVAILABLE"}.get(status, "UNKNOWN")
    return {"domain": domain, "tier": 1, "state": state, "note": "",
            "precedence": it.get("precedence"),
            "hints": list(it.get("hints") or []),
            "quote": {
                "registration": (it.get("pricing") or {}).get("registration"),
                "renewal": (it.get("pricing") or {}).get("renewal"),
                "currency": (it.get("pricing") or {}).get("currency"),
                "premium": it.get("premium"),
            }}


def tier1_public_all(domains, timeout=25):
    """Tier 1 rows for a whole shortlist, one connection per distinct label."""
    by_label = {}
    for d in domains:
        label, tld = split_label(d)
        if not label:
            by_label.setdefault(None, []).append((d, None))
            continue
        by_label.setdefault(label, []).append((d, tld))

    rows_by_domain = {}
    for label, group in by_label.items():
        if label is None:
            for d, _ in group:
                rows_by_domain[d] = _tier1_row(d, None, "not a two-label name")
            continue
        tlds = sorted(set(t for _, t in group if t))
        items, err = public_search(label, tlds, timeout=timeout)
        for d, _ in group:
            rows_by_domain[d] = _tier1_row(d, (items or {}).get(d), err)
        if label != list(by_label)[-1]:
            # Distinct labels still need a gap, but one connection now covers
            # every extension sharing a label, which is where the calls were.
            time.sleep(1.0)
    return [rows_by_domain[d] for d in domains]


# --------------------------------------------------------------------------
# tier 0 - IANA bootstrap + registry RDAP
# --------------------------------------------------------------------------

def load_bootstrap(offline_path=None):
    """Return {tld: [server urls]} from IANA, cached on disk for a day."""
    raw = None
    if offline_path:
        with open(offline_path, "rb") as fh:
            raw = fh.read()
    else:
        try:
            if os.path.exists(CACHE_PATH):
                age = time.time() - os.path.getmtime(CACHE_PATH)
                if age < BOOTSTRAP_TTL:
                    with open(CACHE_PATH, "rb") as fh:
                        raw = fh.read()
        except OSError:
            raw = None
        if raw is None:
            status, body, err = fetch(BOOTSTRAP_URL, retries=2)
            if status != 200:
                raise SystemExit(
                    "Could not load the IANA RDAP bootstrap (%s). "
                    "Pass --bootstrap-file with a local copy." % (err or "HTTP %s" % status)
                )
            raw = body
            try:
                os.makedirs(os.path.dirname(CACHE_PATH), exist_ok=True)
                with open(CACHE_PATH, "wb") as fh:
                    fh.write(raw)
            except OSError:
                pass  # cache is a convenience, not a requirement
    doc = json.loads(raw.decode("utf-8"))
    table = {}
    for entry in doc.get("services", []):
        tlds, servers = entry[0], entry[1]
        servers = [s if s.endswith("/") else s + "/" for s in servers]
        servers = [s.replace("http://", "https://") for s in servers]
        for tld in tlds:
            table[tld.lower()] = servers
    return table


def rdap_server_for(domain, table):
    labels = domain.lower().strip(".").split(".")
    if len(labels) < 2:
        return None, None
    tld = labels[-1]
    return table.get(tld), tld


def rdap_url(server, domain):
    return "%sdomain/%s" % (server, domain)


def vcard_fn(entity):
    """Pull the formatted name out of a jCard, wherever it is."""
    card = entity.get("vcardArray")
    if not isinstance(card, list) or len(card) < 2:
        return None
    for item in card[1]:
        if isinstance(item, list) and len(item) >= 4 and item[0] == "fn":
            return item[3]
    return None


def parse_rdap(doc, accessed):
    """Reduce a registry RDAP object to the rows an operator actually reads."""
    out = {
        "handle": doc.get("handle"),
        "name": (doc.get("ldhName") or "").lower(),
        "status": [s.lower() for s in doc.get("status", [])],
        "accessed": accessed,
        "registrar": None,
        "events": {},
        "nameservers": [],
        "dnssec": None,
    }
    for ev in doc.get("events", []):
        action = ev.get("eventAction")
        if action:
            out["events"][action] = ev.get("eventDate")
    for ent in doc.get("entities", []):
        if "registrar" in (ent.get("roles") or []):
            out["registrar"] = vcard_fn(ent) or ent.get("handle")
            break
    for ns in doc.get("nameservers", []):
        # Registries are inconsistent about the trailing dot. Strip it, or the
        # Cloudflare check below silently misses every .co.uk style answer.
        nm = (ns.get("ldhName") or "").lower().rstrip(".")
        if nm:
            out["nameservers"].append(nm)
    sd = doc.get("secureDNS")
    if isinstance(sd, dict):
        out["dnssec"] = bool(sd.get("delegationSigned"))
    return out


def days_until(date_str):
    if not date_str:
        return None
    txt = re.sub(r"\.\d+", "", str(date_str)).replace("Z", "")
    for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%d"):
        try:
            t = time.strptime(txt[: len(fmt) + 2].strip(), fmt)
            delta = time.mktime(t) - time.time()
            return int(delta // 86400)
        except ValueError:
            continue
    return None


def years_since(date_str):
    d = days_until(date_str)
    if d is None:
        return None
    return round(abs(d) / 365.25, 1)


def is_cloudflare_ns(nameservers):
    return any(ns.endswith(".ns.cloudflare.com") for ns in nameservers)


def tier0_lookup(domain, table, accessed):
    """One registry-state row. Every field is either measured or None."""
    row = {
        "domain": domain.lower(),
        "tier": 0,
        "state": "UNKNOWN",
        "note": "",
        "accessed": accessed,
        "rdap_server": None,
        "record": None,
    }
    servers, tld = rdap_server_for(domain, table)
    if not servers:
        row["note"] = "No RDAP service registered for .%s at IANA" % (tld or "?")
        row["state"] = "NO_RDAP"
        return row
    row["rdap_server"] = servers[0]
    status, body, err = fetch(rdap_url(servers[0], domain), retries=1)
    if status == 200:
        try:
            rec = parse_rdap(json.loads(body.decode("utf-8")), accessed)
        except (ValueError, UnicodeDecodeError) as e:
            row["state"] = "UNKNOWN"
            row["note"] = "RDAP returned 200 but the body did not parse (%s)" % e
            return row
        row["record"] = rec
        if not rec["nameservers"] and not rec["events"]:
            # Some registries answer 200 with a near-empty object for names they
            # do not hold. Treat a record with no events and no NS as no record.
            row["state"] = "NO_RECORD"
            row["note"] = "200 with no events and no nameservers; treat as no registry record"
        else:
            row["state"] = "REGISTERED"
        return row
    if status == 404:
        row["state"] = "NO_RECORD"
        row["note"] = "Registry has no record. Confirm at the registry before assuming it is for sale."
        return row
    row["state"] = "UNKNOWN"
    row["note"] = err or ("HTTP %s from %s" % (status, servers[0]))
    return row


def tier0_flags(row):
    """Human-readable consequences of the raw record."""
    flags = []
    rec = row.get("record") or {}
    if row["state"] != "REGISTERED":
        return flags
    exp = rec["events"].get("expiration")
    d = days_until(exp)
    if d is not None:
        if d < 0:
            flags.append("EXPIRED %d days ago" % abs(d))
        elif d <= EXPIRING_DAYS:
            flags.append("EXPIRES in %d days" % d)
    age = years_since(rec["events"].get("registration"))
    if age is not None and age >= 10:
        flags.append("AGED %sy - verify history before treating as clean" % age)
    for s in rec["status"]:
        if s in BLOCKING_STATUS:
            flags.append("STATUS %s - not transferable/registrable as-is" % s)
    if is_cloudflare_ns(rec["nameservers"]):
        flags.append("ON CLOUDFLARE nameservers already")
    if rec["dnssec"] is False:
        flags.append("DNSSEC unsigned")
    return flags


# --------------------------------------------------------------------------
# tier 2 - Cloudflare Registrar API quote (token)
# --------------------------------------------------------------------------

def cf_credentials():
    tok = os.environ.get("CLOUDFLARE_API_TOKEN")
    acct = os.environ.get("CLOUDFLARE_ACCOUNT_ID")
    if not tok or not acct:
        return None, None
    return tok, acct


def cf_domain_check(domains):
    """POST domain-check in chunks of 20. Returns (rows, error)."""
    tok, acct = cf_credentials()
    if not tok:
        return None, (
            "No CLOUDFLARE_API_TOKEN / CLOUDFLARE_ACCOUNT_ID in the environment. "
            "Tier 2 needs both; Tiers 0 and 1 do not."
        )
    rows = []
    for i in range(0, len(domains), CF_CHECK_MAX):
        chunk = domains[i : i + CF_CHECK_MAX]
        payload = json.dumps({"domains": chunk}).encode("utf-8")
        status, body, err = fetch(
            "%s/accounts/%s/registrar/domain-check" % (CF_API, acct),
            headers={
                "Authorization": "Bearer %s" % tok,
                "Content-Type": "application/json",
                "Accept": "application/json",
            },
            data=payload,
            method="POST",
            retries=1,
        )
        if status != 200:
            hint = ""
            if status in (401, 403):
                hint = (
                    " Token rejected. domain-check needs Registrar *write* permission "
                    "plus a billing profile and a default registrant contact on the account."
                )
            return None, "domain-check failed: %s%s" % (err or "HTTP %s" % status, hint)
        try:
            doc = json.loads(body.decode("utf-8"))
        except (ValueError, UnicodeDecodeError) as e:
            return None, "domain-check returned unparseable JSON (%s)" % e
        if not doc.get("success"):
            msgs = "; ".join(
                "%s: %s" % (e.get("code"), e.get("message")) for e in doc.get("errors", [])
            )
            return None, "API refused the call (%s)" % (msgs or "no error detail")
        rows.extend(doc.get("result", {}).get("domains", []))
    return rows, None


def print_cf_rows(rows, accessed):
    print("TIER 2 - Cloudflare Registrar quote (registry-direct, %s)" % accessed)
    print("")
    head = "%-30s %-13s %-9s %-11s %-11s %s" % (
        "DOMAIN", "REGISTRABLE", "TIER", "REG COST", "RENEW COST", "WHY NOT",
    )
    print(head)
    print("-" * len(head))
    for r in rows:
        name = r.get("name", "?")
        ok = r.get("registrable")
        pricing = r.get("pricing") or {}
        cur = pricing.get("currency", "")
        reg = pricing.get("registration_cost")
        ren = pricing.get("renewal_cost")
        reason = r.get("reason") or ""
        why = CF_REASON_HELP.get(reason, reason) if reason else ""
        print("%-30s %-13s %-9s %-11s %-11s %s" % (
            name,
            "yes" if ok else "no",
            r.get("tier", ""),
            ("%s %s" % (cur, reg)).strip() if reg else "-",
            ("%s %s" % (cur, ren)).strip() if ren else "-",
            why,
        ))
    print("")
    quoted = [r for r in rows if r.get("registrable") and (r.get("pricing") or {}).get("registration_cost")]
    if quoted:
        print("%d of %d names came back with a price. These numbers are a quote for" % (len(quoted), len(rows)))
        print("this moment: re-run Check immediately before you register, because a")
        print("registry fee change moves the number with no notice to this script.")
    else:
        print("No price came back. Read the WHY NOT column before assuming the")
        print("extension is unavailable - extension_not_supported_via_api means the")
        print("dashboard may still sell it.")


# --------------------------------------------------------------------------
# reporting
# --------------------------------------------------------------------------

def print_tier0(rows, accessed):
    print("TIER 0 - registry state, no account used (%s)" % accessed)
    print("")
    head = "%-32s %-11s %-14s %-12s %s" % (
        "DOMAIN", "STATE", "REGISTERED", "EXPIRES", "NS / REGISTRAR",
    )
    print(head)
    print("-" * len(head))
    for row in rows:
        rec = row.get("record") or {}
        reg = (rec.get("events") or {}).get("registration")
        exp = (rec.get("events") or {}).get("expiration")
        ns = rec.get("nameservers") or []
        who = ns[0] if ns else (rec.get("registrar") or "")
        print("%-32s %-11s %-14s %-12s %s" % (
            row["domain"],
            row["state"],
            (reg or "-")[:10],
            (exp or "-")[:10],
            who[:40] or "-",
        ))
    print("")
    for row in rows:
        flags = tier0_flags(row)
        if flags:
            print("%s:" % row["domain"])
            for f in flags:
                print("  - %s" % f)
    notes = [(r["domain"], r["note"]) for r in rows if r["note"]]
    if notes:
        print("")
        for name, note in notes:
            print("%s: %s" % (name, note))
    print("")
    print("Tier 0 reports registry state and no price. Add --public for the free")
    print("price pass, --cloudflare for the token-scoped one, or both.")


def print_tier1_public(rows, accessed):
    print("TIER 1 - Cloudflare public price catalog (no account, %s)" % accessed)
    print("")
    head = "%-30s %-15s %-10s %-10s %-5s %s" % (
        "DOMAIN", "STATE", "REG*", "RENEW*", "CCY", "PREMIUM",
    )
    print(head)
    print("-" * len(head))
    for r in rows:
        q = r.get("quote") or {}
        reg = q.get("registration")
        ren = q.get("renewal")
        prem = q.get("premium")
        # The catalog quotes a list price whether or not the name is taken.
        # Showing it beside TAKEN invites someone to read $0.20 as "cheap".
        buyable = r["state"] == "AVAILABLE"
        print("%-30s %-15s %-10s %-10s %-5s %s" % (
            r["domain"],
            r["state"],
            ("%.2f" % reg) if (buyable and isinstance(reg, (int, float))) else "-",
            ("%.2f" % ren) if (buyable and isinstance(ren, (int, float))) else "-",
            q.get("currency") if buyable else "-",
            ("yes" if prem else ("no" if prem is False else "?")) if buyable else "-",
        ))
    print("")
    taken = [r for r in rows if r["state"] == "TAKEN"]
    if taken:
        print("* price suppressed: TAKEN names carry a catalog list price that reads")
        print("  like an offer and is not one. The extension list price still applies:")
        for r in taken:
            q = r.get("quote") or {}
            if isinstance(q.get("registration"), (int, float)):
                print("    %-28s %s %.2f reg / %s %.2f renew%s" % (
                    r["domain"], q.get("currency"), q["registration"],
                    q.get("currency"), q.get("renewal") or 0,
                    "  PREMIUM" if q.get("premium") else ""))
        print("")
    hinted = [r for r in rows if r.get("hints")]
    if hinted:
        print("Catalog hints worth reading before you quote a renewal:")
        for r in hinted:
            print("    %-28s %s" % (r["domain"], ", ".join(r["hints"])))
        print("")
    shallow = [
        r for r in rows
        if r.get("precedence") is not None
        and r["precedence"] < AUTHORITATIVE_PRECEDENCE
    ]
    if shallow:
        print("Some rows never reached the catalog's final resolution stage. Treat")
        print("their prices as provisional:")
        for r in shallow:
            print("    %-28s precedence=%s" % (r["domain"], r.get("precedence")))
        print("")
    for r in rows:
        if r["state"] in ("UNKNOWN", "NOT_IN_CATALOG") and r.get("note"):
            print("%s: %s" % (r["domain"], r["note"]))
    if any(r["state"] in ("UNKNOWN", "NOT_IN_CATALOG") for r in rows):
        print("")
    print("These are Cloudflare's own catalog prices, read off the same socket the")
    print("public search page uses. A price here is a quote, not a contract: re-read")
    print("it the day you buy, and check the premium column. Tier 2 is what tells you")
    print("whether the API will sell you the name at all.")


def main():
    ap = argparse.ArgumentParser(
        description="Registry state (free) then Cloudflare Registrar price quote (token)."
    )
    ap.add_argument("domains", nargs="*", help="domain names to check")
    ap.add_argument("--file", help="read candidate domains from a file, one per line")
    ap.add_argument("--public", action="store_true",
                    help="Tier 1: price via Cloudflare's public catalog socket (no account)")
    ap.add_argument("--cloudflare", action="store_true",
                    help="Tier 2: registrable + price via the Registrar API (token)")
    ap.add_argument("--json", action="store_true", help="machine-readable output")
    ap.add_argument("--bootstrap-file", help="use a local copy of the IANA bootstrap JSON")
    ap.add_argument("--tlds", action="store_true", help="print which TLDs have an RDAP server, then exit")
    ap.add_argument("--limit", type=int, default=0, help="cap the number of candidates processed")
    args = ap.parse_args()

    accessed = time.strftime("%Y-%m-%d")

    try:
        table = load_bootstrap(args.bootstrap_file)
    except SystemExit as e:
        print(str(e), file=sys.stderr)
        return 2

    if args.tlds:
        tlds = sorted(table.keys())
        print("%d TLDs have an RDAP server in the IANA bootstrap." % len(tlds))
        if args.json:
            print(json.dumps(tlds))
        else:
            for i in range(0, len(tlds), 12):
                print("  " + "  ".join(tlds[i : i + 12]))
        return 0

    domains = list(args.domains)
    if args.file:
        with open(args.file) as fh:
            for line in fh:
                line = line.split("#")[0].strip()
                if line:
                    domains.append(line)
    seen, cleaned = set(), []
    for d in domains:
        d = d.lower().strip().rstrip(".")
        if d and d not in seen:
            seen.add(d)
            cleaned.append(d)
    if args.limit:
        cleaned = cleaned[: args.limit]
    if not cleaned:
        ap.print_help()
        return 2

    rows = [tier0_lookup(d, table, accessed) for d in cleaned]

    # Tier 1 costs nothing, so it runs over every name that Tier 0 did not
    # already prove is registered - including the NO_RDAP ones, which RDAP
    # cannot answer but the price catalog often can.
    pub_rows, pub_err = (None, None)
    if args.public:
        pub_candidates = [r["domain"] for r in rows if r["state"] != "REGISTERED"]
        skipped = [r["domain"] for r in rows if r["state"] == "REGISTERED"]
        if skipped:
            # stderr, not stdout: with --json, stdout has to stay parseable by
            # the downstream scripts SKILL.md promises this flag is for.
            sys.stderr.write(
                "Tier 1 skipped %d already-registered name(s): %s\n"
                % (len(skipped), ", ".join(skipped))
            )
        if pub_candidates:
            pub_rows = tier1_public_all(pub_candidates)

    cf_rows, cf_err = (None, None)
    if args.cloudflare:
        candidates = [r["domain"] for r in rows if r["state"] in ("NO_RECORD", "NO_RDAP", "UNKNOWN")]
        skipped = [r["domain"] for r in rows if r["state"] == "REGISTERED"]
        if skipped:
            sys.stderr.write(
                "Tier 2 skipped %d already-registered name(s): %s\n"
                % (len(skipped), ", ".join(skipped))
            )
        if candidates:
            cf_rows, cf_err = cf_domain_check(candidates)

    if args.json:
        print(json.dumps({
            "accessed": accessed,
            "tier0": rows,
            "tier1_public": pub_rows,
            "tier1_error": pub_err,
            "tier2_api": cf_rows,
            "tier2_error": cf_err,
        }, indent=2, sort_keys=True))
        return 0

    print_tier0(rows, accessed)
    print("")
    if args.public:
        if pub_err:
            print("TIER 1 - not run")
            print(pub_err)
        else:
            print_tier1_public(pub_rows or [], accessed)
    else:
        print("TIER 1 - not requested. Add --public for free Cloudflare prices.")
    print("")
    if args.cloudflare:
        if cf_err:
            print("TIER 2 - not run")
            print(cf_err)
        else:
            print_cf_rows(cf_rows or [], accessed)
    else:
        print("TIER 2 - not requested. Add --cloudflare (needs CLOUDFLARE_API_TOKEN")
        print("and CLOUDFLARE_ACCOUNT_ID) to learn whether the API will sell the name.")
    return 0


if __name__ == "__main__":
    sys.exit(main())

Install it in one paste

If you would rather not route the two blocks above by hand, copy everything between the markers and paste it into your Codex session. It reads those two blocks, writes them into the right place, and runs the smoke test. If you are using Claude Code instead, paste the same text; it handles the same instructions.

Code
<PASTE_TO_CODEX>
You will install a Codex skill. Read the two code blocks in the page you were pasted from: the block that starts with the YAML frontmatter `name: codex-seo-domain-check` and the block that starts with `#!/usr/bin/env python3` (which contains the string `domain_check.py`).

1. Create the directory ~/.codex/skills/codex-seo-domain-check/scripts.
2. Save the YAML block to ~/.codex/skills/codex-seo-domain-check/SKILL.md (only the frontmatter and body; do not include the fence lines).
3. Save the Python block to ~/.codex/skills/codex-seo-domain-check/scripts/domain_check.py (keep the shebang and everything after it).
4. Run: python3 ~/.codex/skills/codex-seo-domain-check/scripts/domain_check.py --tlds
5. Then run: python3 ~/.codex/skills/codex-seo-domain-check/scripts/domain_check.py example.com example.co.uk --public
6. Report back: how many TLDs the bootstrap returned, the state each of the two domains came back as, and the registration and renewal price the free pass showed for each, with its currency. If the price pass fails with a TLS or 403 error, say so and report the registry states on their own - do not fill the gap with a remembered number. Do not attempt the token-scoped pass; there is no token in this environment.

Do not modify any existing skill directories. Do not touch ~/.codex/skills/ outside the codex-seo-domain-check directory. Do not attempt to register, transfer, or renew anything.
</PASTE_TO_CODEX>

This is an addition to the Codex SEO Skills series, which closed at 20 posts. It exists because the same question kept arriving at the wrong skill: naming a new site, vetting a domain before a transfer, and checking a domain before paying for a placement on it are all registry questions. The full-site audit post runs an inventory pass that lists which skills you have installed; this one slots into that list.

Related in the series: [How to Set Up Codex for a Full-Site SEO Audit (Full SKILL.md Included)](https://auspia.ai/blog/codex-seo-audit) - the orchestrated audit whose inventory step tracks which skills you have installed.

The full series roadmap lists every post, including the finale.

Author: Julian Mercer, 14-Year Technical SEO Practitioner at Auspia. Julian writes about crawlability, schema, rendering, and the technical foundations a site is built on - domains included.

The question is three questions

"Can I get this domain, and what will it cost?"

It reads like one question. It is three, and they have three different answers from three different places.

Is this name taken? A public record. IANA publishes a file that maps 1,200 top-level domains to the RDAP server holding that registry's records. Ask the right server and you get the registration date, the expiry date, the status codes, the registrar, and the nameservers. No account, no key, no quota worth worrying about.

What does Cloudflare charge for it? A quote, and a free one. The public search page at cloudflare.com/domains/search hydrates from a websocket that anyone can open. No login, no token, no account. It gates on an Origin header, which is a formality rather than a credential.

Will Cloudflare actually sell it to me? That one needs an account. POST /registrar/domain-check wants an API token, a billing profile, a default registrant contact, and an accepted registration agreement. It is also the only one of the three that can tell you an extension is priced but not purchasable.

Every "domain checker" that blurs the three produces the same errors: a cached price presented as today's, a 404 presented as "available", and a price presented as an offer. All three are avoidable once you keep the questions apart.

Tier 0

Tier 1

Tier 2

The question

Is this name taken?

What does Cloudflare charge?

Will Cloudflare sell it to me?

The source

the registry, found through IANA's RDAP bootstrap

Cloudflare's public search websocket

Cloudflare Registrar domain-check

What it needs

python3 and a network

python3 and a network

API token, account ID, billing profile, registrant contact

What it costs

nothing

nothing

nothing either, but capped at 20 names per call

Authority

definitive on registry state

Cloudflare's catalog, quoted live

registry-direct, and the only tier that answers "sellable"

What comes back

state, dates, status codes, nameservers

state, registration cost, renewal cost, premium flag

registrable, tier, both costs, a reason code when the answer is no

Flow diagram: a candidate shortlist feeds the IANA RDAP bootstrap, which resolves each TLD to its registry server; the registry returns state and flags; a gate labelled NO_RECORD is not available passes only survivors to the free Cloudflare price catalog, read over a public websocket; a second gate marked TOKEN passes the survivors to the Cloudflare domain-check endpoint, which returns four reason codes that split into two buckets: taken, or try the dashboard.
Candidate names enter once. The two free passes remove everything the registry already holds and price what is left, and only the survivors reach the token-scoped check.

Who this is for, and what done looks like

Who should use this

Anyone naming a new site, auditing domains they already depend on, or vetting a domain before a transfer or a paid placement

The finished outcome

A candidate list filtered to the names that are actually buyable, plus a live Cloudflare price for each survivor

What you need before you start

Python 3.8 or newer. Tiers 0 and 1 need nothing else, though Tier 1 needs a Python linked against OpenSSL rather than LibreSSL, because the websocket checks your TLS fingerprint. Tier 2 additionally needs a Cloudflare account with Registrar access

Time to install and verify

About five minutes. The Cloudflare token adds setup time the first time you do it

Definition of done

Every name in the report carries a state you measured, every price carries the currency and the date you asked, and no line says "available"

Why no price table survives a year

The temptation with a domain skill is to ship a table of TLD prices so it works offline. Resist it. Three things move underneath that table, and all three have moved recently.

Verisign announced on 23 April 2026 that it will raise the .com wholesale registry fee from $10.26 to $10.97 on 1 November 2026, a 7% increase permitted under its agreement with NTIA and ICANN. That single change moves the floor under every .com price on the internet, and it is not the only one coming: the contract allows 7% annually through the final four years of the current six-year term.

The ICANN per-domain transaction fee is not the fixed $0.18 that most registrar listings print, either. ICANN's fee schedule raised the registrar transaction fee to $0.20 on 1 July 2025, and 2026 listings still confidently quote the old number. When the two figures that make up a floor are both moving and both disputed, a hardcoded table has no chance.

And Cloudflare's own position is a pricing model, not a price. The Registrar product page states that Cloudflare "does not mark up domain prices at all" and that registration, transfer, and renewal prices are "at or below what registries and ICANN charge us". That tells you the shape of the number. It does not tell you the number.

So this skill carries no price table. It carries a lookup.

Step 1: let IANA tell you where to ask

There is no single RDAP server. Each registry runs its own, and IANA maintains the directory that maps TLDs to them. The script reads https://data.iana.org/rdap/dns.json, caches it for a day, and looks up the TLD of whatever you hand it.

Code
1200 TLDs have an RDAP server in the IANA bootstrap.
  aaa  aarp  abb  abbott  abbvie  abc  able  abogado  abudhabi  academy  accenture  accountant
  accountants  aco  actor  ad  ads  adult  aeg  aero  aetna  afl  africa  agakhan

Coverage is good but not total. Those 1,200 TLDs sit across 590 registry services, and a TLD absent from the file is a TLD you cannot check this way. .af is one of them. The skill reports that as NO_RDAP rather than falling back to a guess, which matters more than it sounds: a checker that silently fails to find a registry will confidently tell you a name is free.

Step 2: run the free pass over the whole shortlist

This is the step that saves the most time, and it is the one people skip because they think they already know which names are taken.

Here is a real run. Six candidates for a new tool site, checked on 2026-09-16 with no account of any kind:

Code
TIER 0 - registry state, no account used (2026-09-16)

DOMAIN                           STATE       REGISTERED     EXPIRES      NS / REGISTRAR
---------------------------------------------------------------------------------------
answerwatch.com                  REGISTERED  2024-09-02     2027-09-02   grace.ns.cloudflare.com
citationwatch.com                REGISTERED  2026-03-18     2027-03-18   meg.ns.cloudflare.com
llmvisibilitylab.com             REGISTERED  2025-08-13     2027-08-13   ns45.domaincontrol.com
geo-answer-lab.com               NO_RECORD   -              -            -
aeo-audit-tool.com               NO_RECORD   -              -            -
answerledger.com                 REGISTERED  2025-04-01     2027-04-01   ns43.domaincontrol.com

answerwatch.com:
  - ON CLOUDFLARE nameservers already
  - DNSSEC unsigned
citationwatch.com:
  - ON CLOUDFLARE nameservers already
  - DNSSEC unsigned
llmvisibilitylab.com:
  - DNSSEC unsigned
answerledger.com:
  - DNSSEC unsigned

geo-answer-lab.com: Registry has no record. Confirm at the registry before assuming it is for sale.
aeo-audit-tool.com: Registry has no record. Confirm at the registry before assuming it is for sale.

Tier 0 reports registry state and no price. Add --public for the free
price pass, --cloudflare for the token-scoped one, or both.

TIER 1 - not requested. Add --public for free Cloudflare prices.

TIER 2 - not requested. Add --cloudflare (needs CLOUDFLARE_API_TOKEN
and CLOUDFLARE_ACCOUNT_ID) to learn whether the API will sell the name.

Four of six were gone. Two of those four were already sitting on Cloudflare nameservers, meaning somebody is running them on Cloudflare DNS right now. The shortlist that felt open was two-thirds closed, and finding that out cost nothing.

It took eleven to fifteen seconds across runs. The same six names took twenty-three on a slow afternoon, and the difference was not the cache: the clock here is six registries answering at their own pace, not your machine.

Step 3: read the flags before the state

REGISTERED is the least interesting word in that output. The state tells you a record exists; the flags tell you whether the name is any use to you.

Flag

What it means for your decision

EXPIRES in N days

Under 45 days. If you are about to depend on this name, you are about to depend on somebody else's renewal

EXPIRED N days ago

Already lapsed. Recovery runs through a redemption period with real money attached, and the current holder may still renew

STATUS clientHold / serverHold

Out of the zone. Whatever the name once ranked for, it is not serving now

STATUS pendingDelete / redemptionPeriod

The owner already lost it. You are watching a queue, not a shop

STATUS clientTransferProhibited

Your transfer request gets refused. This is the default at most registrars, and it is why transfers start with unlocking

AGED 17.6y

Worth a history check before you treat it as a clean slate

ON CLOUDFLARE nameservers already

Useful on its own. In a link-vetting context it tells you the operator is already inside the Cloudflare ecosystem

DNSSEC unsigned

Not a blocker. A signal about how carefully the zone is run

The status codes are where a hurried check goes wrong. A domain can return a complete, healthy-looking record and still be impossible to transfer, because clientTransferProhibited is on by default at most registrars. Reading the state and stopping there is how you end up promising a client a domain you cannot move.

Four-row card comparing registry states REGISTERED, NO_RECORD, NO_RDAP and UNKNOWN, each with what it means and what it does not prove, ending with the reminder to read the flags, not the state.
Four states the skill can return, and the one thing each does not tell you.

Step 4: price the survivors, for free

You do not need the API to get a Cloudflare price. Open the search page in a browser and watch the network tab, and you will find the page is not fetching a price list at all. It is holding a websocket open:

Code
wss://search.registrar.cloudflare.com/v1/ws?session_id=<any uuid>
Origin: https://www.cloudflare.com

Drop the Origin header and the handshake answers 403 Forbidden. Send it and you get 101 Switching Protocols. That is the entire gate. The tenant on the other side is named public_registrar, which should tell you how much of a secret this is meant to be.

There is one other way to get that identical 403 while doing everything right, and it costs people an afternoon: the edge also refuses the handshake when the client's TLS stack is LibreSSL, which is what macOS's system Python links. Both causes return the same hint-free refusal, so check your interpreter before you go hunting for a missing header:

Code
python3 -c "import ssl; print(ssl.OPENSSL_VERSION)"

If that prints LibreSSL, run the script on a Python built against OpenSSL 3.x - Homebrew's python3.11 is one - and the same command returns prices.

Here is the same shortlist with --public added, still with no account of any kind. The Tier 0 table above prints again, unchanged, so this is the part that is new:

Code
Tier 1 skipped 4 already-registered name(s): answerwatch.com, citationwatch.com, llmvisibilitylab.com, answerledger.com

TIER 1 - Cloudflare public price catalog (no account, 2026-09-16)

DOMAIN                         STATE           REG*       RENEW*     CCY   PREMIUM
----------------------------------------------------------------------------------
geo-answer-lab.com             AVAILABLE       10.46      10.46      USD   no
aeo-audit-tool.com             AVAILABLE       10.46      10.46      USD   no

These are Cloudflare's own catalog prices, read off the same socket the
public search page uses. A price here is a quote, not a contract: re-read
it the day you buy, and check the premium column. Tier 2 is what tells you
whether the API will sell you the name at all.

Two survivors at $10.46 to register and $10.46 to renew. That figure is not arbitrary, and it is worth doing the arithmetic once: Verisign's current .com wholesale fee is $10.26 and ICANN's per-domain transaction fee is $0.20. That is $10.46 to the cent, which is what "no markup" looks like when it is a number instead of a slogan. When the wholesale fee rises to $10.97 on 1 November 2026, expect this row to read $11.17, and note that nobody has to tell you that. You just re-run it.

The catalog resolves each row in stages and labels the stage precedence. Zero is the snapshot placeholder, one is the preliminary answer. Here is a trace of a single .dev name, captured while probing the socket:

Code
precedence 0   snapshot   status unknown   $12.20 / $12.20
precedence 1   patch      status taken     $12.20 / $12.20
precedence 2   patch      status taken     $0.20 / $329.20   premium + hint

Both the rung-one and the rung-two answer are well-formed JSON. Only one of them is true, and the difference is a renewal that costs sixteen times what the preliminary number suggested.

Where it gets interesting is that the ladder has two terminal rungs, not one, and neither is a ceiling:

Outcome

Commits at

Measured

taken, premium, or otherwise unusual

rung 2

example.dev $0.20 / $329.20 premium; example.co $500 / $500 premium

available at a normal price

rung 3

geo-answer-lab.com $10.46 / $10.46, with premium flipping from null to false on the way

never resolves

stays at rung 1

answerwatch.com, taken, still at rung 1 after a full window

So "stop when precedence reaches 2" is wrong twice over. It is the terminal rung for one branch only, and the page's own client treats the order as open-ended and monotonic: its merge logic drops any patch whose precedence is lower than the row's current one, which means a rung-3 answer arriving after a rung-2 patch is a legitimate override rather than a duplicate. Wait for the row to stop changing.

That is the bug this script shipped with for an afternoon, in two acts. The first version stopped at the first non-unknown status and quoted the preliminary price. The second version stopped at rung 2 and merged every incoming patch blindly, which meant a late results.snapshot carrying precedence: 0 for all 400-odd candidates could overwrite a row a delta had already resolved. That is what made the same .co name return $15.00 on one run and $500.00, premium on another, minutes apart, from identical code. The script now applies the client's own merge rule, where a lower rung never overwrites a higher one, and then waits for the row to go quiet.

The ? you may see in the premium column is what a genuinely unresolved row looks like, and the script prints a provisional warning underneath when it happens. That is a real signal, not noise: it means the catalog never committed, so the number beside it is a preliminary answer. Re-run it rather than quoting it. In the run above, both rows committed, which is why the column reads no and there is no warning.

The hints array is where the catalog explains itself. non_standard_renewal_fee is the one to watch for, and it shows up precisely when the headline price and the real renewal have come apart.

Step 5: the check that actually needs a token

Tier 2 is one endpoint. The script chunks your list 20 at a time, which is the documented cap per request:

Code
curl --request POST \
  --url "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/registrar/domain-check" \
  --header "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
  --header "Content-Type: application/json" \
  --data '{"domains": ["acmecorp.dev"]}'

A registrable name comes back with a pricing object carrying currency, registration_cost, and renewal_cost. All three are strings, deliberately, so the decimals survive the round trip. Parse them for display, never for arithmetic you then quote.

The token requirements are stricter than they look, and a read-only token fails in a way that reads like a bug. Before domain-check returns anything, the account needs an API token with Registrar write permission, a billing profile with a valid default payment method, a default registrant contact, and the Domain Registration Agreement accepted. If you get a flat 403, that list is the checklist, not the endpoint.

Tier 1 and Tier 2 answer different questions, so when they disagree neither one is lying. Tier 1 quotes the catalog. Tier 2 asks the registry. A name can carry a live Tier 1 price and still come back registrable: false with extension_not_supported_via_api, which means the catalog will happily quote you a number for something the API will not sell you.

One honest gap in this post. The Tier 0 and Tier 1 outputs above are real runs from this machine, pasted unedited. The Tier 2 rows are not, because this machine has no Cloudflare Registrar credentials and I am not going to invent a price to fill the space. Tier 2 is documented here from Cloudflare's own reference and implemented in the script, but that one column you will have to generate yourself. A fabricated price in a pricing tool is worse than an empty column.

The gate: a 404 is not a yes

The five steps above end here, with a rule rather than an action, because this is the one the whole skill is built around.

NO_RECORD is not available. Repeat that to whoever reads your report.

An RDAP 404 means the registry has no record for that string right now. It does not mean the name is purchasable, and the difference is not academic:

  • Reserved and premium names can return 404 and still be unsellable, or sellable only at a premium this lookup never sees.
  • If the TLD has no RDAP service registered at IANA at all, you get NO_RDAP, which is an absence of information rather than a green light.
  • Some registries answer 200 with a near-empty object for names they do not hold. The script treats "no events and no nameservers" as NO_RECORD, not as a registration.
  • Registry state can change between your check and your purchase. Cloudflare's own documentation says to run Check immediately before registering, which is exactly the admission that state is perishable.

Here is what the honest failure looks like:

Code
TIER 0 - registry state, no account used (2026-09-16)

DOMAIN                           STATE       REGISTERED     EXPIRES      NS / REGISTRAR
---------------------------------------------------------------------------------------
brandname.af                     NO_RDAP     -              -            -


brandname.af: No RDAP service registered for .af at IANA

Tier 0 reports registry state and no price. Add --public for the free
price pass, --cloudflare for the token-scoped one, or both.

TIER 1 - not requested. Add --public for free Cloudflare prices.

TIER 2 - not requested. Add --cloudflare (needs CLOUDFLARE_API_TOKEN
and CLOUDFLARE_ACCOUNT_ID) to learn whether the API will sell the name.

That is a complete answer. It is not a useful one, and it does not pretend to be.

Verify the run

Three checks, in order:

  1. Confirm the server you asked. Run one domain with --json and read rdap_server. For a .com it should be a Verisign host; for a .uk it should be Nominet's. If the server does not belong to the registry you expected, stop.
  2. Confirm the answer is about the name you typed. The output's name field is the registry's own ldhName, lowercased. A near-miss here means a redirect or a typo reached a different record.
  3. Confirm the price is fresh, and final. Both price tiers are quotes, not constants. Re-read them the day you act, and if a Tier 1 row never reached precedence: 2, the script already told you it is provisional.

Reproduce these runs

Code
python3 domain_check.py --tlds
python3 domain_check.py --file fixtures/candidates.txt
python3 domain_check.py brandname.af
python3 domain_check.py --file fixtures/candidates.txt --public          # free prices, no account
python3 domain_check.py --file fixtures/candidates.txt --public --cloudflare   # needs a token

Run 2 executed against fixtures/candidates.txt, which ships with the skill. Point it at your own shortlist and the shape of the output is identical.

Where this stops

  • Premium is visible in Tier 1 and unsellable in Tier 2. Tier 1 flags premium rows and explains the odd ones through hints. The API beta does not support premium registrations at all, so a name can carry a Tier 1 premium price and still come back registrable: false. The catalog will quote it; the API will not sell it.
  • The API reaches a subset of Cloudflare's 430+ extensions. Some TLDs work in the dashboard and return extension_not_supported_via_api here. Read the reason code, not the boolean: two of the four documented reasons mean "try the dashboard", and only one means "taken".
  • Renewals, transfers, and contact updates are not available through the API. Registration is the only write operation. Everything else is the dashboard.
  • The public socket rate-limits, and the failure looks like a network fault. Probe it repeatedly and the handshake starts failing before TLS even completes, which reads like your connection broke. The client Cloudflare ships carries rateLimit, a softLimit mode called "Reduced results mode", and a deferReconnectUntil timer, so this is designed behaviour. Space the queries out, and treat a sudden handshake failure as "wait", not "the endpoint moved".
  • Tier 1 can stop at a provisional answer. If a row never reaches precedence: 2, the price you are looking at is the preliminary one. The script says so when it happens. Do not quietly quote the number anyway, and do not average the two.
  • This is not a valuation tool. Registration date and nameserver are inputs to due diligence on a domain you might buy or link to. They are not a quality score, and a clean registry record says nothing about whether the site behind the name is any good.

FAQ

Why not just check WHOIS? RDAP is the structured successor that IANA points you to, it returns JSON instead of free text, and it works over HTTPS without the port-43 problems that make WHOIS awkward to automate. For any TLD where both exist, RDAP is the one to script against. Where a TLD has neither, this skill says so instead of guessing.

Can I price 200 names at once? Not in one Tier 2 call, where the cap is 20 domains per request. The free tiers take more: Tier 0 has no limit worth worrying about, and Tier 1 prices one label across every extension Cloudflare carries in a single query, so 200 names sharing a few labels is a handful of queries. Run the free passes over the whole list first and let them cut it, then send only the surviving names to Tier 2. The script already skips names that Tier 0 proved registered.

The price I got differs from what the dashboard shows. Which is wrong? Neither, usually. Tier 1 reads the same catalog the dashboard's search reads, so those two should agree, and a mismatch there means one of you is looking at a stale cache. When Tier 1 and Tier 2 disagree, that is not a bug either: Tier 1 quotes the catalog and Tier 2 asks the registry. Quote the Tier 2 number for anything you are about to act on.

Should I trust a $10.44 figure I saw for .com? Not from a table, and not from a blog post, including this one. Ask domain-check on the day. The .com wholesale fee itself changes on 1 November 2026, and any number printed before that date is describing the old regime.

Is this useful for link buying? It is one input. Registry age and the nameserver tell you the domain is not three weeks old and not freshly moved. They do not tell you whether the site has traffic, whether it is indexed, or whether the link is worth the money. Treat it as the cheapest filter in a longer process, not as a verdict.

What does it do with an IDN? RDAP expects punycode. Convert before you pass the name in, and the record will come back in punycode rather than the display form.

How often should I re-run it? On every decision. Tier 0 is free, so there is no reason to quote a stale state to anyone. Re-run at the moment of the decision, not from yesterday's file.

Explore this topic

Keep following the same growth thread