Domain expiry reference
The wire-level schema is in the auto-generated API Reference. This page is the human-readable cheat sheet. The API calls these DNS jobs; see the overview for why.
The check and a run of the check are different objects
Section titled “The check and a run of the check are different objects”A domain check is served as two shapes, and they answer two different questions. Getting them confused is the single easiest way to misread this API.
| Object | Endpoint | Answers |
|---|---|---|
DnsJob | GET /dns/job/{dns_job_id} | What do we currently believe about this registration? |
DnsJobResult | GET /dns/job/{dns_job_id}/results | What did one particular run observe? |
They agree on every successful run and diverge the moment a run fails.
A failed run learned nothing, so its result row records nothing: expiry_date, domain_status, registrar and nameservers are all null, and failure_reason says why. History has to be able to say “we could not read the registry that day” rather than repeating the previous answer as though it had been confirmed.
The job row does the opposite. It keeps the values the last successful run recorded, moves current_status to unknown, and sets last_failure_reason and last_failure_at. That is deliberate: clearing the date over a rate limit or a timeout would throw away a good date, blank the registrar and nameserver baseline (so the next successful run would read as a first observation and swallow a real registrar change), and re-arm every alert rung, producing a duplicate 30-day warning on recovery.
Object: DnsJob
Section titled “Object: DnsJob”Current best knowledge about the registration.
| Field | Type | Description |
|---|---|---|
id | uuid | Domain check ID. |
account_id | uuid | Owning account. |
http_job_id | uuid | The HTTP check this was paired with. |
current_status | enum | See Status. Starts at unknown before the first run, and returns to unknown when a run fails. |
domain | string | The registrable domain derived from the HTTP check’s URL (https://www.example.com/path becomes example.com). |
expiry_date | rfc3339 | null | The registration expiry currently in force, whichever leg of the lookup chain produced it. null when nothing has produced one: before the first run, and on a TLD that publishes none and has no manual date set. |
expiry_source | string | null | Where expiry_date came from: rdap, whois, whoxy, or manual. null whenever expiry_date is null. Read this before treating a date as authoritative: rdap and whois are the registry’s own answer, whoxy is a commercial aggregator, and manual is a date somebody at your company typed. |
manual_expiry_date | rfc3339 | null | The renewal date you entered yourself, via PUT /dns/job/{dns_job_id}. null when none is set. Customer-maintained: nothing updates it on renewal. See Manual renewal dates. |
registry_publishes_expiry | bool | false on the TLDs established to publish no expiry date to anybody. This is the flag to key “offer a manual renewal date” off. true for every TLD not on that list, including any we have not examined, so a true here is not a promise that a date will arrive. |
domain_status | string[] | null | EPP status codes, in RFC 8056 form: lowercase with spaces, so auto renew period, not autoRenewPeriod. |
grace_states | string[] | The subset of domain_status that means the registration is in trouble. See Registry states that alert. Derived server-side, never null, and [] when there is nothing wrong. This is the field to key “needs attention” off, not the date. |
registrar | string | null | Sponsoring registrar. |
nameservers | string[] | null | Nameserver set, lowercased and sorted. |
last_run_at | rfc3339 | null | When the check last ran, successfully or not. |
last_status_change_at | rfc3339 | null | When current_status last transitioned. |
last_failure_reason | string | null | Why the last run produced no registration record, if it produced none. See Failure reasons. Non-null means the registry fields above are last known rather than current, with one exception: registry_publishes_no_expiry is a state, not a failure, and must not be rendered as a broken check. Cleared by the next run that reads a record. |
last_failure_at | rfc3339 | null | When that failure happened. |
created_at | rfc3339 | When the check was provisioned. |
Object: DnsJobResult
Section titled “Object: DnsJobResult”One row per run, kept as history. Every field describes that run, so a failed run carries a failure_reason and nulls, never the previous answer.
| Field | Type | Description |
|---|---|---|
id | uuid | Result ID. |
account_id | uuid | Owning account. |
dns_job_id | uuid | The check this result belongs to. |
created_at | rfc3339 | When the run happened. |
status | enum | See Status. Always unknown when failure_reason is set. |
failure_reason | string | null | Set when this run could not read the registry. See Failure reasons. |
expiry_date | rfc3339 | null | The expiry date this run observed. null on a failed run, and null when a leg answered but published no expiration event. A manual renewal date is not an observation, so it never appears here. |
domain_status | string[] | null | EPP status codes this run observed, RFC 8056 form. null on a failed run. |
grace_states | string[] | Derived from this run’s domain_status. Never null; [] on a failed run. |
registrar | string | null | Sponsoring registrar this run observed. null on a failed run. |
nameservers | string[] | null | Nameservers this run observed, lowercased and sorted. null on a failed run. |
rdap_origin | string | null | Which RDAP endpoint served this run: iana when it came from IANA’s bootstrap registry, override when it came from the two hardcoded entries (.io and .co). null on a failed run, and null when the RDAP leg was not the one that answered. |
The registry fields are kept per run so history can answer “what did the registry say on the day this broke”. The registry state and change alerts are decided by comparing a run’s observation against what the job already knew, which is why a failed run never fires one.
Object: DnsOverview
Section titled “Object: DnsOverview”The monitor overview, GET /http/job/{job_id}/overview, embeds a compact domain summary as its dns key. This is what the dashboard’s Domain tile reads. It is absent when the HTTP check has no domain monitoring turned on.
| Field | Type | Description |
|---|---|---|
status | enum | See Status. |
domain | string | The registrable domain being monitored. |
expiry_date | rfc3339 | null | Same value as the job’s, with the same last-known caveat. |
days_until_expiry | integer | null | Whole days from now to expiry_date, truncated toward zero, so a registration lapsing in 13 hours reads 0. Negative once it has passed. |
grace_states | string[] | Same derived list as on the job. Never null. |
last_failure_reason | string | null | Set when the last run produced no record, so a UI can mark the date as last known rather than current. registry_publishes_no_expiry also arrives here and is a state, not a failure. |
last_run_at | rfc3339 | null | When the domain check last ran. |
Status
Section titled “Status”| Status | Meaning |
|---|---|
good | There is an expiry date and it is in the future. |
expired | There is an expiry date and it has passed. |
unknown | No usable expiry date. Three different situations produce it: the check has not run yet, the lookup failed, or the TLD publishes no expiry date at all. Read the failure reason to tell them apart: null before the first run, registry_publishes_no_expiry for the third case, and one of the other reasons for a real failure. The field is failure_reason on a result and last_failure_reason on the check itself. |
Registry states that alert
Section titled “Registry states that alert”Recorded per run from the registration’s EPP status codes, in RFC 8056 form (lowercase with spaces).
| State | Typical window | Meaning |
|---|---|---|
auto renew period | ~45 days | Renewed automatically by the registry after a missed renewal. The registrar can still cancel for a credit, which deletes the domain. |
redemption period | ~30 days | Deleted, recoverable for a restoration fee. |
pending delete | ~5 days | Past redemption. Drops, and cannot be recovered. |
pending restore | short | A restore was requested and is not complete. |
client hold | until cleared | The registrar removed the domain from the zone. It does not resolve. |
server hold | until cleared | The registry removed the domain from the zone. It does not resolve. |
Every other EPP code (ok, active, client transfer prohibited, and the rest) is recorded and never alerts.
Failure reasons
Section titled “Failure reasons”Recorded when a run ends with no expiry date. The check reports unknown in every case here, but only some of them mean anything went wrong.
| Reason | Meaning | Retried |
|---|---|---|
registry_publishes_no_expiry | Not a failure. This TLD publishes no expiry date to anybody, under any protocol, by registry policy. The chain was walked and there is nothing to find. Set a manual renewal date instead. | No, and there is nothing to retry |
no_rdap_endpoint | No RDAP service is listed for the TLD, in IANA’s bootstrap registry or our overrides. On its own this does not end a run: it advances the chain to port-43 WHOIS. It is recorded only when no later leg had a record either. | No |
registry_no_record | The source returned 404 for this name. Like the row above, this advances the chain rather than ending the run, and is recorded only when nothing later had a record. It is not proof the domain is unregistered: registries also answer 404 for a subdomain of a live domain, and RFC 7480 permits a 404 in place of admitting to rate limiting. | No |
rate_limited | The source returned 429. Unexpected at this volume and worth reporting if it persists. | Yes, against the same leg |
transport_error | The request failed or timed out. The per-request timeout is 10 seconds. | Yes, against the same leg |
malformed_response | The source answered with something that would not parse. | Yes, against the same leg |
Alert reasons
Section titled “Alert reasons”Every alert carries a stable reason slug. This is the field to switch on in a webhook consumer.
| Slug | Fires when |
|---|---|
domain_expiring | A ladder rung is crossed: 90, 60, 30 or 7 days remaining, or the expiry date is today. Once per rung, per expiry date. |
domain_expired | The expiry date has already passed. Once. |
domain_grace_state | The domain newly entered one of the registry states above. Once per state entered. |
domain_registrar_changed | The sponsoring registrar changed from the previously recorded one. |
domain_nameservers_changed | The nameserver set changed from the previously recorded one. |
Webhook payload
Section titled “Webhook payload”A webhook channel on the parent HTTP check’s notification group receives a POST with a JSON body of this shape. The four original keys keep their exact names and meaning; reason, headline and detail were added in August 2026 and are additive.
{ "data": { "dns_job_id": "0f6a2c1e-9d5b-4a3f-8c11-2b7e4d0a6c93", "monitor_name": "Marketing site", "domain": "example.com", "expiry_date": "2026-09-14", "reason": "domain_grace_state", "headline": "example.com is in auto renew period", "detail": "The registry reports this domain as auto renew period. This usually means the registration was not renewed on time and the registry is holding it. The expiry date may still read as far away, because a registry auto-renew moves it forward while the registrar can still hand the domain back. Confirm the renewal with your registrar." }}| Key | Notes |
|---|---|
dns_job_id | The domain check, not the HTTP check. |
monitor_name | The parent HTTP check’s friendly name. |
domain | The registrable domain being monitored. |
expiry_date | YYYY-MM-DD, not RFC 3339. The literal string "unknown" when the registry publishes no expiry, which is a real case for registrar and nameserver change alerts. |
reason | One of the alert reason slugs. |
headline | One short sentence, always naming the domain. |
detail | The explanatory sentence beneath the headline. Written for a human, not parsed. |
Where the expiry date comes from
Section titled “Where the expiry date comes from”Three sources, tried in order, and named by expiry_source on the check.
expiry_source | Source | Tried when |
|---|---|---|
rdap | The registry’s own RDAP service, specifically the expiration event in its response. Located through IANA’s bootstrap file, plus two hardcoded entries for .io and .co, which run RDAP but are missing from it. | Always, first. |
whois | The registry’s own WHOIS server, on port 43. | The TLD has no RDAP service listed, or RDAP had no record for this name. |
whoxy | A commercial registration database. Catches registries that refuse our connections and formats we would otherwise hand-parse. | Neither of the above produced a record. |
manual | A date you entered. See Manual renewal dates. | It is set. |
RDAP is contractually mandatory for gTLDs and entirely optional for ccTLDs, which is the whole reason for legs 2 and 3: IANA’s bootstrap file lists an RDAP service for roughly 1,200 of the 1,438 TLDs in the root, and reading RDAP alone meant a .me domain got no expiry check at all.
The chain advances only on “no endpoint for this TLD” and “no record for this name”. Every other outcome stops the run and is retried against the same leg. See the failure reasons.
rdap and whois are both the registry’s own answer and are equally authoritative. whoxy is a third party’s copy, so a whoxy date is worth a second look before you act on it as fact.
Manual renewal dates
Section titled “Manual renewal dates”Eleven TLDs publish no expiry date to anybody: .am, .at, .be, .de, .eu, .gg, .im, .nl, .no and .nz. They report registry_publishes_expiry: false and last_failure_reason: registry_publishes_no_expiry. For those, you supply the date.
# Set itcurl -X PUT https://api.siteqwality.com/dns/job/$DNS_JOB_ID \ -H "Authorization: Bearer $SITEQWALITY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "manual_expiry_date": "2027-03-01T00:00:00Z" }'
# Clear itcurl -X PUT https://api.siteqwality.com/dns/job/$DNS_JOB_ID \ -H "Authorization: Bearer $SITEQWALITY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "manual_expiry_date": null }'Both forms return the updated DnsJob.
With a date set, the check behaves exactly as it would on a registry date: the ladder fires at 90, 60, 30 and 7 days and then on the lapse, each rung once per date. expiry_source reads manual. registry_publishes_expiry stays false, because it describes the TLD rather than your domain.
Notification channels
Section titled “Notification channels”Domain alerts fan out through the parent HTTP check’s notification groups. Email, SMS, Slack, webhook, Discord, Microsoft Teams, Google Chat, Mattermost, PagerDuty, OpsGenie, Pushover and Pushbullet all receive them.
Telegram channels do not. Telegram is configurable and has a working transport, but no monitor type in SiteQwality produces a Telegram notification today, so a Telegram channel on the group is skipped for domain alerts.
Maintenance windows do not suppress domain alerts, deliberately. A renewal deadline does not pause for scheduled work.
Cadence and lifecycle
Section titled “Cadence and lifecycle”| Property | Value |
|---|---|
| Schedule | Once a day, with a flexible window of up to 4 hours |
| Configurable | No. There is no interval field on a domain check. |
| Alert thresholds | Fixed at 90, 60, 30 and 7 days plus the lapse itself. Not configurable. |
| Enable | PUT /http/job/{job_id} with monitor_dns: true, or the toggle on the HTTP check |
| Disable | The same with monitor_dns: false. This deletes the domain check and its schedule. |
| Manual renewal date | PUT /dns/job/{dns_job_id} with manual_expiry_date. See Manual renewal dates. |
Endpoints
Section titled “Endpoints”You create a domain check implicitly, by setting monitor_dns: true on an HTTP check. Its own endpoints are read-only apart from the manual renewal date.
| Method | Path | Purpose |
|---|---|---|
GET | /dns/job/{dns_job_id} | Fetch the check and its current knowledge. Returns a DnsJob. |
PUT | /dns/job/{dns_job_id} | Set or clear the manual renewal date. Body is { "manual_expiry_date": "2027-03-01T00:00:00Z" }, or { "manual_expiry_date": null } to clear it. Returns the updated DnsJob. |
GET | /dns/job/{dns_job_id}/recent | The single most recent result. Returns a DnsJobResult. |
GET | /dns/job/{dns_job_id}/results?limit=50 | Recent results, newest first. limit defaults to 50 and is clamped to 200. |
GET | /http/job/{job_id}/overview | The parent HTTP check’s overview, whose dns key carries a DnsOverview. |
See also
Section titled “See also”- Domain expiry overview: the three alert signals, and the auto-renew trap.
- Quickstart: turn it on and read the first result.
- HTTP checks reference: the parent check.