names.dev Platform API v1

The names.dev Platform API lets a platform check, quote, register and run domains for the brands it launches, entirely through API calls and signed webhooks, at wholesale prices and with test money in a full sandbox.

0. Principles

  1. API-first. Anything a human can do in the names.dev UI is doable through this API. Your brands never need to see a names.dev screen.
  2. Honest state. available is only ever true when an authoritative source (a registrar partner or the registry) said so in this request. Everything else is a labeled status (unknown, likely_available), never a guess presented as fact. The live API never reports a success it did not get from a partner.
  3. Wholesale only. Prices are what names.dev charges your platform. Retail prices are never returned (price_view=retail is reserved, not in v1).
  4. Async by design. Every call that touches a registry returns an operation immediately (HTTP 202) and finishes by webhook; operations can also be polled.
  5. Safe to retry. Every write requires Idempotency-Key. Same key and same request return the original response.
  6. Never hold a domain hostage, never drop one silently. Transfer-out always works within registry rules; billing failures never lapse a domain without days of warnings.
  7. No patient data, ever. Schemas are closed: unknown fields are rejected, and metadata values are identifiers, not free text.
  8. A human decides what only a human may decide. Trademark claims notices and registrant verification are answered by a person at the brand. Agents must never click through them.

1. Base, versions, environments

  • Base URL: https://names.dev/api/platform/v1 (alias https://api.names.dev/platform/v1 when DNS allows).
  • Versioning: path-versioned. Breaking changes ship as /v2 with at least 90 days' notice; additive changes are listed at GET /changelog.
  • Environments are chosen by the key, never by the URL: nd_test_... keys use the sandbox (sandbox registry, test money, forced outcomes); nd_live_... keys use live partners. Objects never cross environments.
  • Public, unauthenticated: GET /openapi.json (the OpenAPI 3.1 document), GET /status (API and per-partner health), GET /changelog. Every other route needs a key.
  • Lists share one envelope: { "object": "list", "data": [...], "has_more": bool, "next_cursor": string | null }. Pass cursor (or starting_after on event lists) to get the next page; limit is 1 to 100.
  • Money is integer USD cents everywhere.

2. Authentication, scopes, limits, tracing

  • Authorization: Bearer nd_test_xxx. A key anywhere in the URL (path, query value, or api_key/key/token parameters) is rejected with 401 key_in_url; treat that key as leaked.
  • Several active keys per environment allow zero-downtime rotation (create a new key, deploy it, revoke the old one). Keys are stored as SHA-256 hashes; only the prefix (nd_test_ab12...) is shown after creation. A key can only create keys for its own environment and with a subset of its own scopes.
  • Scopes (optional, default all): read, register, dns, transfer, billing, keys. A call outside the key's scopes returns 403 scope_required. Sandbox controls need register, transfer, billing or keys as listed in section 6.
  • Optional per-key IP allowlist (IPv4 or IPv6 CIDR). A mismatch returns 403 ip_not_allowed.
  • Rate limits per key: 25 requests per second sustained, burst 50 (a token bucket shared across all names.dev servers). Bulk availability counts as one request. Every response carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset; over the limit returns 429 rate_limited with Retry-After. Unauthenticated calls are limited per IP.
  • Request ids: send X-Request-Id (up to 128 characters of [A-Za-z0-9._:-]) and we echo it; otherwise we mint req_.... Every response carries X-Request-Id, and every error body carries request_id.

3. Idempotency

  • Required on every POST, PUT, PATCH and DELETE, except the read-style POSTs /availability, /availability/bulk, /suggestions, /screening and /domains/{id}/contacts/preview. A missing key returns 400 idempotency_key_required.
  • Keys are 1 to 255 printable ASCII characters, scoped to (account, environment) and remembered for 48 hours (contract minimum: 24 hours).
  • Replay: same key with the same method, path, body and Names-Sandbox-Outcome header returns the stored status and body plus Idempotent-Replayed: true. Never a second charge or a second registration.
  • Same key with a different request returns 409 idempotency_key_reused. Same key while the first request is still running returns 409 idempotency_in_progress (retryable, Retry-After: 1).
  • Responses with status 500 or above are not stored: retrying with the same key runs the request again.
  • If an operation fails, the quote it used goes back to open until it expires, so you can retry with a new Idempotency-Key without quoting again.

4. Errors

json
{ "error": { "code": "quote_expired", "message": "Quote q_123 expired at 2026-11-13T18:00:00Z. Request a new quote.",
  "retryable": false, "request_id": "req_abc", "details": { "quote_id": "q_123" },
  "doc_url": "https://names.dev/docs/platform/errors#quote_expired" } }
codeHTTPretryablemeaning
validation_failed400nobody, query or header invalid; details.issues[]
field_not_allowed400nounknown or free-text field sent (no patient data)
idempotency_key_required400nowrite without Idempotency-Key
key_in_url401noAPI key found in the URL
unauthorized401nomissing, invalid or revoked key
scope_required403nokey lacks the scope (or tried to grant scopes it does not hold)
ip_not_allowed403nocaller IP outside the key's allowlist
sandbox_only403nosandbox control or Names-Sandbox-Outcome used with a live key
not_found404noobject (or route) does not exist in this environment
idempotency_key_reused409nosame key, different request
idempotency_in_progress409yesoriginal request still running
name_unavailable409noname is registered, reserved, listed on the aftermarket, or not listed
quote_expired409noquote past expires_at
quote_used409noquote already consumed by another operation
quote_mismatch409noquote is for another domain, type or term (or the domain's state needs another quote type)
price_changed409no(live) partner price moved beyond the tolerance above the quote; the quote is void
eligibility_required422noTLD needs proof (details.requirement, for example nabp_token or registrant_id); refused before any charge
claims_notice_required422noname is in a Trademark Claims period; show details.claims_notice to a human and send their claims_acceptance
unsupported_tld422noTLD not offered
term_out_of_range422noyears outside the TLD's create or renew minimum and maximum
invalid_contact422nocontact fails registry rules; details.issues[]
invalid_nameservers422nonot 2 to 13 valid, distinct, out-of-bailiwick hostnames
dnssec_unsupported422noTLD or partner can't take DS records
domain_locked409noa lock or the domain's state blocks the action; details.reason, details.unlocks_at, details.policy_version
transfer_not_allowed409noregistry rules block the transfer; details.reason
add_grace_expired409norefund delete requested after add-grace (or the TLD has none)
not_restorable409nodomain not in redemption, or the TLD has no restore
price_on_request409noaftermarket listing has no fixed price; can't be quoted
insufficient_balance402nobalance plus credit line can't cover; nothing charged
payment_method_required402nolive top-up without a card or invoicing arrangement on file; nothing charged
rate_limited429yessee Retry-After
registry_timeout504yespartner or registry didn't answer in time
registry_unavailable503yespartner or registry outage, or partner not configured; details.retry_after_seconds, details.partner
internal_error500yesour fault; safe to retry with the same idempotency key

Errors are returned before any charge whenever the cause is knowable up front: expired quote, unavailable name, invalid contact, insufficient balance, eligibility, claims notice, term limits.

5. Objects

5.1 Contact (closed schema)

json
{ "kind": "organization", "organization": "Northwind Health LLC", "name": "Ops Desk",
  "email": "domains@northwind.example", "phone": "+1.4155550100",
  "address": { "line1": "1 Market St", "line2": null, "city": "San Francisco", "region": "CA",
               "postal_code": "94105", "country": "US" } }
  • Any other field returns field_not_allowed. Contacts are stored encrypted at rest (AES-256-GCM) and are never sent in webhooks, events or the audit log.
  • Registry rules (invalid_contact): phone uses EPP format +CC.NUMBER; country is ISO 3166-1 alpha-2; US and CA addresses need a valid region and postal_code; .us needs a US-nexus registrant.
  • organization is required when kind is organization. Under the ICANN Registration Data Policy (in force since 2025-08-21) a filled Registrant Organization becomes the legal registrant (Registered Name Holder), so send the brand's legal entity, not a department name.
  • Registration data is redacted from public RDAP/WHOIS by default. privacy (section 6) adds a privacy/proxy service where the TLD allows it; a proxy becomes the registrant of record, so for health brands redaction alone is often the better choice (privacy: false). .pharmacy and .us do not allow proxies.
  • Only the registrant is sent upstream; admin, tech and billing are kept by names.dev and sent only where a registry requires them.

5.2 Metadata

metadata: { "brand_id": "brd_123", "org_id": "org_9", "tags": ["launch-2026q4"] } plus up to 20 custom keys. Keys match ^[a-z][a-z0-9_]{0,39}$, values are at most 128 characters of ^[A-Za-z0-9_.:@/-]*$, tags are at most 10 of 40 characters. Values are identifiers, not prose. Your platform may use its own key names as custom keys (for example <platform>_brand_id). Lists filter by metadata: GET /domains?metadata[brand_id]=brd_123.

5.3 Availability result

json
{ "domain": "lumen.health", "tld": "health", "status": "available",
  "available": true, "authoritative": true, "premium": false,
  "source": { "kind": "registry", "partner": "sandbox", "listing_url": null },
  "price": { "currency": "USD", "registration": 6200, "renewal": 6200, "years": 1, "unit": "cents" },
  "delivery": "instant", "reason": null, "checked_at": "2026-11-13T18:00:00.123Z", "latency_ms": 41 }
  • status: available | likely_available (a non-authoritative hint such as RDAP says it looks clear; verify at quote time) | taken | premium (registry premium, registrable now at the premium price) | aftermarket (listed for sale, async purchase) | reserved | restricted (eligibility needed) | unknown.
  • available is true only for available and premium with authoritative: true.
  • source.kind: registry | aftermarket; source.partner: namecom | openprovider | atom | rdap | sandbox | ...
  • reason explains rows that are not a plain authoritative answer, for example partner_not_configured, registry_unavailable, rdap_hint, unsupported_tld, eligibility_required.
  • Aftermarket rows add price.purchase (fixed price) or price_on_request: true, and delivery: "async" with delivery_estimate_hours.
  • restricted rows add eligibility_requirement (for example nabp_token).
  • Rows in a Trademark Claims period add claims_notice (section 8.1).
  • Rows add notices[] when the TLD requires a disclosure before purchase (for example .app and .dev are HSTS-preloaded: sites only load over HTTPS).
  • price.renewal is per year. Premium names renew at the premium price where the registry says so (.co, .health, .app).

5.4 Quote

json
{ "id": "q_01M...", "object": "quote", "domain": "lumen.health", "type": "registration", "years": 1,
  "currency": "USD", "amount": 6200, "renewal_amount": 6200, "premium": false,
  "source": { "kind": "registry", "partner": "sandbox" },
  "expires_at": "2026-11-13T18:15:00Z", "status": "open", "used_by_operation_id": null, "created_at": "..." }
  • type: registration | renewal | transfer | restore | premium_purchase.
  • status: open | used | expired | void (a live price change voided it).
  • Registry quotes hold for 15 minutes. Aftermarket quotes hold exactly as long as the listing hold at the source (never longer).
  • A quote is consumed by exactly one operation; a failed operation releases it back to open until it expires.
  • renewal_amount is one minimum-term renewal at today's price (.ai: 2 years).
  • Terms: registration and renewal take years within the TLD's limits (.ai at least 2 years for create and renew, .co at most 5 years and never more than 5 years ahead). transfer is fixed at the registry's transfer term; restore includes one minimum renewal term.
  • Registration quotes for restricted TLDs need eligibility, and names in a claims period need claims_acceptance (section 8).

5.5 Domain

json
{ "id": "dom_01M...", "object": "domain", "environment": "sandbox", "name": "lumen.health", "tld": "health",
  "state": "active", "registry_statuses": ["addPeriod", "clientTransferProhibited"],
  "created_at": "...", "registered_at": "...", "expires_at": "2027-11-13T18:02:11Z",
  "auto_renew": true, "privacy": { "enabled": true, "supported": true },
  "transfer_lock": true, "registry_lock": false,
  "transfer_lock_until": "2027-01-12T18:02:11Z",
  "nameservers": ["ns-1.awsdns-01.org", "ns-2.awsdns-02.co.uk"],
  "dnssec": { "supported": true, "ds_records": [] },
  "verification": { "state": "verified", "deadline": null },
  "partner": "sandbox", "premium": false, "metadata": { "brand_id": "brd_123" },
  "grace": { "add_grace_ends_at": "2026-11-18T18:02:11Z", "renew_grace_ends_at": null,
             "redemption_ends_at": null, "restore_price": null },
  "sequence": 4, "updated_at": "..." }
  • state: pending | active | expired (in auto-renew grace) | redemption | pending_delete | deleted | transferring_out | transferred_out | failed.
  • registry_statuses are raw EPP statuses as structured values (ok, clientHold, serverHold, clientTransferProhibited, serverTransferProhibited, pendingDelete, redemptionPeriod, autoRenewPeriod, addPeriod, renewPeriod, transferPeriod, pendingTransfer, clientUpdateProhibited, serverUpdateProhibited, clientDeleteProhibited, serverDeleteProhibited, inactive). Never prose.
  • sequence increases by exactly one with every event about the domain, and the domain's sequence is the latest event's. A gap means you missed an event: reconcile with GET /domains/{id}/events.
  • transfer_lock is the client lock you can toggle; transfer_lock_until is the registry or policy lock you can't.

5.6 Operation

json
{ "id": "op_01M...", "object": "operation", "environment": "sandbox", "type": "register", "status": "pending",
  "domain": "lumen.health", "domain_id": "dom_01M...", "partner": "sandbox",
  "result": null, "error": null,
  "charges": [{ "transaction_id": "txn_...", "type": "registration", "amount": 6200, "status": "held" }],
  "created_at": "...", "updated_at": "...", "completed_at": null, "metadata": { } }
  • type: register | renew | restore | delete | transfer_in | transfer_out | nameservers | dnssec | contacts | change_registrant | premium_purchase | settings (registry lock or privacy changes from PATCH /domains/{id}).
  • status: pending | running | succeeded | failed | canceled.
  • Premium purchases add stage: pending_payment | in_escrow | transferring | delivered | failed | refunded.
  • Transfers add transfer: { direction, status, requested_at, updated_at, auto_approve_at, auth_code_expires_at } with status requested | pending | approved | completed | rejected | canceled. A transfer operation stays running while the registry window is open.
  • charges[].status mirrors the ledger (held, settled, released); refunds appear as negative amounts.
  • error on failure: { code, message, retryable, details? } with the codes of section 4 plus registry_rejected, verification_lapsed, transfer_rejected, sale_failed.
  • Operations on the same domain run in the order they were created.

6. Endpoints

Reads need read. Writes list their scope.

Search, pricing, rules

MethodPathNotes
POST/availability{ name: "lumen", tlds: ["com","health",...up to 20] } or { domains: [up to 20] }. p95 < 800 ms.
POST/availability/bulk{ domains: [up to 100] } or { names: [...], tlds: [...] } (up to 100 combinations). Returns counts. p95 < 2 s.
POST/suggestions{ name, keywords?: [], tlds?: [], limit?: up to 50, screening?: { famous_marks: "exclude" | "flag" } }. Each suggestion carries an availability result; only actionable rows are returned. p95 < 1.5 s.
GET/pricesWholesale price sheets per TLD: terms[] (years, registration, renewal), transfer, restore, effective_at, scheduled_change. Sends an ETag; If-None-Match returns 304. Live answers registry_unavailable when no partner can price.
GET/prices/{tld}One TLD.
GET/tlds / /tlds/{tld}Rules: min_years, max_years, renew_min_years, restricted, eligibility, privacy_allowed, dnssec_supported, idn, premium_names, premium_renews_at_premium, add_grace_days, renew_grace_days, redemption_days, pending_delete_days, restore_available, grace_dns_interruption_days, transfer_supported, transfer_adds_years, transfer_lock_days, transfer_policy, transfer_policy_pending, hsts_preloaded, registrant_notices, nexus, partners[], tmch_claims, unverified_fields, last_verified.
POST/screening{ names: [...], tlds?: [...] }. Per name: famous-mark collisions (score, matched_mark, kind: exact | homoglyph | typosquat) and TMCH claims status per TLD. Risk screening, not legal clearance.

Quotes

MethodPathNotes
POST/quotes (register){ domain, type, years?, eligibility?, claims_acceptance? }. Returns 201 with the quote.
GET/quotes/{id}

Domains

MethodPathNotes
POST/domains (register)Register in one call. Body: quote_id, years, registrant, admin?, tech?, billing? (default to copies of the registrant), nameservers (2 to 13), ds_records?, privacy (default true where allowed), auto_renew (default true), transfer_lock (default true), metadata, eligibility?, claims_acceptance?, optional domain to assert the quote's name. Returns 202 { operation, domain }.
GET/domainsFilters: metadata[key], state, expires_before, expires_after, tld, q; cursor pagination, newest first.
GET/domains/{id_or_name}Full state.
PATCH/domains/{id} (dns)Toggle auto_renew, privacy, transfer_lock; replace metadata. Local-only changes return 200 with the domain; lock or privacy changes at the registry return 202 { operation, domain } (type settings).
PUT/domains/{id}/nameservers (dns){ nameservers: [2 to 13] }. Returns an operation; domain.nameservers_updated when the registry accepts. In-bailiwick nameservers (glue) are not supported in v1.
GET/domains/{id}/dnssecDS records and support.
POST/domains/{id}/dnssec (dns){ ds_records: [{ key_tag, algorithm, digest_type, digest }] } adds records (up to 8 per domain). Returns an operation.
DELETE/domains/{id}/dnssec/{key_tag} (dns)Removes the records with that key tag. Returns an operation.
GET/domains/{id}/propagation{ registry: { nameservers, ds_records, matches_expected }, expected, resolving: { nameservers, ok, source }, checked_at }. Tells "registered" apart from "resolving".
GET/domains/{id}/contactsContacts (registrant, admin, tech, billing).
POST/domains/{id}/contacts/previewSame body as PATCH; returns { changed_roles, material_change, triggers_transfer_lock, transfer_lock_days, transfer_lock_opt_out_allowed, requires_reverification, policy_version } without applying.
PATCH/domains/{id}/contacts (register)Applies. Requires acknowledge_transfer_lock: true when the preview says a lock is triggered; transfer_lock_opt_out: true skips the lock where the policy allows (the registrant's prior consent). Returns an operation.
POST/domains/{id}/change-registrant (transfer){ registrant, acknowledge_transfer_lock?, transfer_lock_opt_out? }. New owner contact; registry confirmations are sent white-label; starts re-verification. Returns an operation.
GET/domains/{id}/verificationICANN registrant verification: state (not_required | pending | verified | lapsed), deadline, last_sent_at.
POST/domains/{id}/verification/resend (register)Re-send the white-label verification email (only while pending or lapsed).
POST/domains/{id}/renew (register){ quote_id, years } returns an operation.
POST/domains/{id}/restore (register){ quote_id } (type restore) returns an operation.
POST/domains/{id}/transfer-out (transfer)Unlocks and prepares the EPP auth code, returning an operation; then GET /domains/{id}/auth-code.
GET/domains/{id}/auth-code (transfer){ auth_code, expires_at }. Never in webhooks.
DELETE/domains/{id} (register){ mode: "refund" } inside add-grace (refund to the balance) or { mode: "let_expire" } (turn off auto-renew). ?mode= works for clients that can't send DELETE bodies. Returns an operation.
GET/domains/{id}/eventsEvents for one domain, in sequence order.

Transfers in

MethodPathNotes
POST/transfers (transfer){ domain, auth_code, quote_id, registrant, admin?, tech?, billing?, nameservers? | keep_nameservers: true, metadata, eligibility? } returns an operation (transfer_in.* events).
GET/transfers/{id}The transfer operation.
POST/transfers/{id}/cancel (transfer)Cancel while the transfer is open; the hold is released.

Premium and aftermarket

MethodPathNotes
POST/premium-purchases (register){ quote_id, registrant, nameservers, ds_records?, metadata, eligibility? } returns an operation with stage. Only after the brand has paid your platform; no offers or auctions in v1.
GET/premium-purchases/{id}

Operations and events

MethodPathNotes
GET/operations/{id}
GET/operationsFilters: domain_id, type, status, created_after.
GET/eventsFilters: type (repeatable or comma-separated), domain_id, created_after, created_before, starting_after. Oldest first, for reconciliation.
GET/events/{id}
POST/events/{id}/redeliver (keys){ endpoint_id? } re-sends one event to all (or one) endpoints.
POST/events/redeliver (keys){ created_after, created_before, types?, endpoint_id? } replays a time range (up to 5,000 events per call).

Webhook endpoints

MethodPathNotes
POST/webhook-endpoints (keys){ url (public https), events: ["*"] | [...], description? }. Returns the signing secret (whsec_...) once. Up to 16 per environment.
GET/webhook-endpoints / /{id}
PATCH/webhook-endpoints/{id} (keys)url, events, description, disabled.
DELETE/webhook-endpoints/{id} (keys)
POST/webhook-endpoints/{id}/rotate-secret (keys){ overlap_hours?: 0 to 168 } (default 24). Returns the new secret once; old secrets keep signing until the overlap ends.
GET/webhook-endpoints/{id}/deliveriesAttempts with status codes, timings and errors.

Billing

MethodPathNotes
GET/balance{ available, held, credit_line, credit_used, spendable, low_balance_threshold, currency }. spendable = available + credit_line - credit_used.
PATCH/balance (billing){ low_balance_threshold }
POST/balance/top-ups (billing){ amount }. Sandbox: instant test money. Live: payment_method_required until names.dev billing has a card or invoicing arrangement on file. Returns a transaction.
GET/transactionsEach entry carries domain, domain_id, operation_id, type (registration | renewal | transfer | restore | premium | refund | top_up | adjustment), direction (debit | credit), amount, tax, status (held | settled | released), metadata (copied from the domain).
GET/transactions.csvSame, as CSV.
GET/statements?month=2026-11Monthly statement: opening balance, charges by type, refunds, top-ups, adjustments, closing balance, open holds.

Keys and audit

MethodPathNotes
GET/POST/api-keys (keys)Create returns the secret once; scopes, ip_allowlist, name. Up to 20 active keys per environment.
DELETE/api-keys/{id} (keys)Revoke.
GET/audit-logEvery write: key_id, request_id, method, path, status, object_ids, ip, idempotency_key, replayed, details (for example a human claims acceptance), at.

Sandbox controls (nd_test_ keys only)

MethodPathNotes
POST/sandbox/operations/{id}/resolve (register){ outcome: "succeeded" | "failed", error_code? }. Finishes an operation left pending.
POST/sandbox/clock/advance (register){ days: 1 to 1000 }. Moves this account's sandbox clock a day at a time and runs the lifecycle (expiry warnings, auto-renew, grace, redemption, deletion, verification deadlines, transfer and aftermarket timers).
POST/sandbox/domains/{id}/expire (register)Jumps an active domain to expiry now. With auto-renew on, the held-open renewal retries on the next day.
POST/sandbox/domains/{id}/verification (register){ action: "require" | "complete" | "lapse" }
POST/sandbox/transfers/{id}/resolve (transfer){ outcome: "approved" | "rejected" | "completed" | "canceled" }
POST/sandbox/premium-purchases/{id}/advance (register){ stage: "in_escrow" | "transferring" | "delivered" | "failed" }
POST/sandbox/events (keys){ type, domain_id?, data? } fires any event type to your endpoints. abuse.notice gets a respond_by from the TLD's SLA.
POST/sandbox/balance (billing){ available, credit_line? } sets the test balance (for example 0 to force insufficient_balance).
POST/sandbox/prices/change (billing){ tld, registration?, renewal?, effective_in_days } schedules a price change and emits price.changed.

Forcing outcomes on any write (sandbox only): header Names-Sandbox-Outcome: succeed (default) · fail:<error_code> (for example fail:registry_timeout; the operation is accepted with 202 and then fails with that code, releasing any hold) · pending (stays pending until /sandbox/operations/{id}/resolve) · verify (registration succeeds and registrant verification is required). Validation errors that are knowable up front (bad contact, expired quote, and so on) are tested with real inputs instead.

Test names (sandbox registry, any TLD): labels starting taken are registered; premium are registry premium; market are aftermarket listings (async purchase); ask are aftermarket with price_on_request; slow respond after 1.5 s; outage return registry_unavailable; pharma are always restricted; claims are in a Trademark Claims period. Everything else is available at the sandbox test price list. Names you register in the sandbox are then taken in your sandbox.

Sandbox eligibility and transfers: NABP tokens look like NABP-TEST-...; any registrant_id is accepted for .md. Inbound transfers with an auth code of invalid (or starting bad) are rejected; others wait for the 5-day window (or /sandbox/transfers/{id}/resolve). keep_nameservers reports ns1/ns2.sandbox-registry.test. Sandbox prices are test data, not a price list.

7. Webhooks

Delivery: POST JSON to each subscribed endpoint. The first attempt happens right after the triggering request (target: within 10 seconds), with a 10-second timeout. Retries are scheduled 1m, 5m, 30m, 2h, 6h, 12h, 24h, 48h and 72h after the first attempt; after the 72-hour attempt the delivery is failed and stays listed. Delivery is at-least-once: dedupe on the event id.

Headers:

text
Names-Event-Id: evt_01M...
Names-Event-Type: domain.registered
Names-Delivery-Id: whd_01M...
Names-Timestamp: 1763056931
Names-Signature: key_id=whk_01M...;v1=5c1f...e9, key_id=whk_01M...;v1=81aa...03

v1 = hex(HMAC_SHA256(secret, "{Names-Timestamp}.{raw_body}")). During rotation each active secret signs (one entry per key_id); accept the event if any entry verifies with a secret you hold. Reject timestamps more than 5 minutes old.

Body:

json
{ "id": "evt_01M...", "object": "event", "type": "domain.registered", "created_at": "...",
  "environment": "sandbox", "sequence": 3,
  "data": { "domain_id": "dom_...", "domain": "lumen.health", "operation_id": "op_...", "state": "active",
            "registry_statuses": ["addPeriod", "clientTransferProhibited"],
            "expires_at": "...", "metadata": { "brand_id": "brd_123" } } }

Payloads carry ids, names, states, statuses, timestamps and metadata. Never contacts, auth codes, eligibility proof or secrets. sequence is per domain: ignore an event whose sequence is at or below the last one you applied for that domain. Events not about a domain (price.changed, account-level balance.*) have sequence: null.

Event types: operation.succeeded, operation.failed, domain.registered, domain.verification_required, domain.verification_completed, domain.verification_lapsed, domain.nameservers_updated, domain.dnssec_updated, domain.contacts_updated, domain.expiring (60/30/14/7/1 days, data.days), domain.renewed, domain.renewal_failed, domain.expired, domain.redemption, domain.restored, domain.deleted, domain.status_changed (data.added, data.removed, optional data.reason), domain.out_of_band_change, transfer_in.requested|pending|approved|completed|rejected|canceled, transfer_out.requested|pending|approved|completed|rejected|canceled, premium.pending_payment|in_escrow|transferring|delivered|failed|refunded, price.changed, balance.low, balance.insufficient, abuse.notice (data.category, data.respond_by, data.sla_hours).

8. Lifecycle, compliance and money rules

  • Register: quote checked (open, same domain, type and term), eligibility and claims checked, availability re-checked live, then balance check (available plus credit line), hold, registry call. On success the hold settles and domain.registered plus operation.succeeded are emitted; on failure the hold is released and operation.failed is emitted. No charge settles without a registered domain.
  • Live price moves: no upstream holds a price for 15 minutes. At register time the partner's current price is compared with the quote: a move of up to 5% (configurable) is absorbed by names.dev; anything above fails with price_changed before any charge and voids the quote.
  • Expiry warnings at 60, 30, 14, 7 and 1 days before expires_at (a superset of the ICANN reminders at about 30 and about 7 days), plus domain.expired at expiry.
  • Auto-renew runs 3 days before expiry. If balance plus credit line can't cover it, balance.insufficient goes out at T-30 and T-14 at the latest; then the renewal is held open and retried daily through the grace period, with domain.renewal_failed (error.code = insufficient_balance) on each failed attempt. A domain is never dropped without these warnings. Premium names renew at the premium price where the registry says so.
  • Grace: after expiry state=expired (autoRenewPeriod) for the TLD's renew-grace days. For the last 8 days of renew grace DNS is interrupted (clientHold, domain.status_changed with reason: expired_dns_interruption), as ICANN's expired-registration policy requires; renewing lifts it. Then redemption (restore at restore_price, quote type restore), then pending_delete (5 days), then deleted. Some ccTLDs differ: .md has a 30-day renew-only grace and is then deleted, with no redemption and no restore.
  • Add-grace delete: DELETE /domains/{id} {mode:"refund"} inside the TLD's add-grace window refunds the registration to the balance (refund transaction, domain.deleted).
  • Transfer-out never requires your platform to be paid up; it only follows registry rules.
  • Price changes: at least 30 days' notice via price.changed (effective_at). Open quotes are honored until they expire.
  • Out-of-band changes: any nameserver, contact or lock change not made through your keys emits domain.out_of_band_change and an email to your security contact.

8.1 Trademark Claims (TMCH)

While a TLD is in a claims period and a label matches a Trademark Clearinghouse record, the claims notice must be shown to a human, in real time, in full, and accepted affirmatively (never pre-checked). .app runs claims permanently; TLDs from the 2026 round launch with at least 90 days of claims.

  1. Availability rows include claims_notice: { id, domain, issued_at, expires_at, notice_text, marks[] }.
  2. Show notice_text and marks to a person at the brand. Record when they accepted.
  3. Send claims_acceptance: { notice_id, accepted_at, accepted_by_human: true } on the quote and on the register call. Without it, or with an expired (48 hours), forged or mismatched notice, both answer 422 claims_notice_required (with a fresh details.claims_notice) before any charge.
  4. The acceptance is written to the audit log (details.claims_acceptance).

An AI agent must never accept a claims notice on a person's behalf.

8.2 Eligibility before any charge

TLDRequirementSend
.pharmacyNABP Healthcare Merchant Accreditation and a per-domain NABP token, redeemable only at an NABP-authorized registrar; non-anonymized registrationeligibility.nabp_token
.mdRegistrant identity (personal ID, company or tax number)eligibility.registrant_id

Missing or invalid proof returns eligibility_required with details.requirement. Proof values are identifiers, stored encrypted, and never echoed in events. In live, .pharmacy is refused until an NABP-authorized partner is configured.

8.3 Registrant verification

Within 15 days of a registration, an inbound transfer or a change of registrant, the registrant must confirm their contact details by answering the white-label verification email (ICANN RAA). A person at the brand must do it; names.dev never auto-confirms and your agents must not either. If the deadline passes the registry puts the domain on clientHold (domain.verification_lapsed) until verification completes.

8.4 Transfer locks are policy data

Locks come from the transfer policy in force, reported on GET /tlds/{tld} as transfer_policy (with policy_version) and in every domain_locked error.

PolicyIn forceAfter createAfter transferAfter registrant changeAuth code lifetime
ICANN Transfer Policy (2024 text)yes60 days60 days60 days unless the registrant opted outno maximum (names.dev issues 14-day codes)
ICANN Transfer Policy Review (adopted 2025-03-12)queued, no effective date30 days30 daysnone14 days, one-time use

ccTLDs follow their registries instead (for example .co: 60/60 with no registrant-change lock; .md: holder-to-holder transfers at the registry only, so transfer_not_allowed). When ICANN announces the revision's effective date, the API switches without a version change; transfer_policy_pending shows what is coming.

8.5 Abuse notices

Abuse reports arrive as abuse.notice with respond_by: 12 hours for .health reports about illicit drugs and for .pharmacy (the registry suspends after that), 24 hours otherwise.

9. Partner routing (live)

RegistrarAdapter interface per partner: check, price, register, renew, restore, delete, transferIn, transferOut, authCode, setNameservers, setDs, setContacts, setLock, setPrivacy, info, and optionally transferStatus for partners without transfer webhooks. A routing table maps each TLD to an ordered partner list. New registrations, quotes and inbound transfers fail over to the next partner on registry_unavailable or a timeout, and the partner used is recorded on the operation; a partner can veto failover when the first attempt may have succeeded upstream. Existing domains always stay with the partner that holds them. Aftermarket adapters: listing, hold, purchase, status. The sandbox is one more adapter, so the same service code runs in both environments. A partner that is not configured answers registry_unavailable ("partner not configured"), and availability then falls back to labeled, non-authoritative RDAP hints.

10. Acceptance scenarios (must pass from API calls and webhooks alone, in the sandbox)

  1. Check one name across 10 TLDs; bulk-check 100 names; get suggestions for a brand name.
  2. Quote, then register with custom nameservers, privacy and auto-renew on; receive domain.registered; read the domain back with the right nameservers and lock.
  3. Retry the same register with the same Idempotency-Key after a client timeout: exactly one domain and one charge.
  4. Register with an expired quote, an unavailable name, an invalid contact and an insufficient balance: each fails before any charge with its stable code.
  5. Add and remove a DS record; change nameservers; both webhooks arrive.
  6. Contact verification required and then completed; and required and then left to its deadline (verification_lapsed, clientHold).
  7. Manual renew; auto-renew; forced renewal failure with warnings.
  8. Transfer out (unlock and EPP code); transfer in with an auth code; the reject and cancel paths.
  9. Delete inside add-grace and see the refund on the balance.
  10. Premium aftermarket purchase through every stage to delivered; forced failure and refund.
  11. Webhook endpoint down for an hour: list events and replay a range with no gaps.
  12. Rotate API keys and webhook signing keys with no downtime.