DNS Cutover Convergence Gate with Webhook Receipt
Pricing
from $10.00 / 1,000 cutover watch starts
DNS Cutover Convergence Gate with Webhook Receipt
Polls public DNS-over-HTTPS resolvers until a record matches the expected value at every resolver, then sends one webhook and writes one sealed receipt. Built for the moment of a cutover, so the next step can start. HTTP only, no browser, no proxy, no dat
Pricing
from $10.00 / 1,000 cutover watch starts
Rating
0.0
(0)
Developer
kingii98
Maintained by CommunityActor stats
0
Bookmarked
2
Total users
1
Monthly active users
6 days ago
Last modified
Categories
Share
You changed a DNS record. Now you must know the exact moment that every resolver serves the new value, so that the next step of the cutover can start.
This Actor polls a set of public DNS-over-HTTPS resolvers until the record matches the value you expect at every one of them. At that moment it sends one webhook POST and writes one sealed receipt. If the record does not converge inside the wait limit, the Actor reports a timeout, sends one webhook POST, and the run still succeeds.
Five instant lookup tools already exist. This one is different in three ways:
- It waits. A lookup tells you the state now. This tells you the moment.
- It notifies. One POST to your URL, so you can chain the next step. Issue the certificate, flip the load balancer, close the change ticket.
- It proves. The receipt holds the run, the moment, the answer of every resolver, and a SHA-256 seal over those facts. Attach it to the change ticket.
HTTP only. No browser, no proxy, no database, no stored state between runs. Every run is self-contained.
How a check converges
A check names a domain, a record type and the value or values that the record must hold after the cutover.
A resolver agrees when the answer set for that record type is exactly the expected set. A superset does not count: a resolver that still serves the old address beside the new one has not finished, and that is precisely the state you are waiting out.
With requireAllResolvers on (the default), the check converges when every
resolver in your list agrees in the same poll cycle. With it off, one agreeing
resolver is enough.
A resolver that fails, times out or answers SERVFAIL does not agree, and the
wait goes on. It never stops the run.
Input
| Field | Type | Default | Meaning |
|---|---|---|---|
checks | array | 3 demonstration checks | 1 to 20 records to watch. |
resolvers | array | 8 public resolvers | HTTPS endpoints that answer an RFC 8484 wire-format GET. |
pollIntervalSeconds | integer | 60 | Wait between two poll cycles. Minimum 30. |
maxWaitMinutes | integer | 30 | The run stops here and reports a timeout. Maximum 60. |
requireAllResolvers | boolean | true | Every resolver must agree, not just one. |
webhookUrl | string | empty | Optional public HTTPS URL for the two events. |
timeoutSeconds | integer | 10 | Timeout for one resolver query and for one webhook POST. |
One check looks like this:
{"domain": "www.example.com","record_type": "A","expected": ["203.0.113.10", "203.0.113.11"]}
Supported record types and their value format:
| Type | Write the expected value as |
|---|---|
A | An IPv4 address, for example 203.0.113.10. |
AAAA | An IPv6 address, for example 2001:db8::1. |
CNAME | A host name, for example cdn.example.net. |
NS | A host name, for example ns1.example.net. |
MX | A preference and a host, for example 10 mail.example.com. |
TXT | The text without the surrounding quotes, for example v=spf1 -all. |
Names are compared without case and without the root dot. Addresses are
compared in their normalized form, so 2001:0db8::0001 equals 2001:db8::1.
An octet with a leading zero such as 093.184.216.34 is refused, because it
reads as octal in some software and as decimal in other software.
A run with empty input uses every default above and watches three stable public records, so you can see the shape of the output before you write your own input.
Output
Every row carries a recordType field that says what kind of row it is.
convergence — one row for each check. This is the primary result.
{"recordType": "convergence","domain": "www.example.com","dnsRecordType": "A","expected": ["203.0.113.10"],"status": "converged","firstConvergedAt": "2026-09-03T10:04:00+00:00","elapsedSeconds": 240.0,"resolversAgreeing": 8,"resolverCount": 8,"answers": {"dns.google": ["203.0.113.10"], "cloudflare-dns.com": ["203.0.113.10"]},"seal": "9f2c...","webhookStatus": 200}
A check that reached the wait limit carries "status": "timeout",
"firstConvergedAt": null and the last answer of each resolver.
poll — one row for each check, at each resolver, in each poll cycle. This
is the time series: polledAt, resolver, answer, ttl, verdict
(match, mismatch or no-answer), status and attempts.
A public resolver sits behind a gateway, and a gateway answers 502, 503 or 504,
drops a connection or returns a SERVFAIL from time to time. Each query is
therefore repeated up to three times with a short pause before the answer is
written to the row, and attempts says how many tries the answer took. A
retry costs no extra event: one poll row is one charged resolver-poll event,
whatever the number of tries.
receipt — one row for each converged check, and the same object in the
key-value store under receipt-<domain>-<type>. The list of all receipts of the
run is stored under the key RECEIPTS.
summary — one row at the end with the verdict of the whole run:
converged, partial or timeout.
The receipt
The receipt is the artefact you attach to a change ticket. It answers "when did this cutover complete, and who says so".
{"runId": "abc123","checkId": "www.example.com/A","expected": ["203.0.113.10"],"convergedAt": "2026-09-03T10:04:00+00:00","answers": {"cloudflare-dns.com": ["203.0.113.10"], "dns.google": ["203.0.113.10"]},"resolverStatus": {"cloudflare-dns.com": "ok", "dns.google": "ok"},"sealAlgorithm": "sha256","seal": "9f2c..."}
The seal detects a later edit of the stored receipt. To recompute it, take the
six fields named in sealedFields, put them in one JSON object with sorted keys
and no spaces, and hash the UTF-8 bytes with SHA-256:
import hashlib, jsonsealed = {name: receipt[name] for name in receipt["sealedFields"]}canonical = json.dumps(sealed, sort_keys=True, separators=(",", ":"), ensure_ascii=False)assert hashlib.sha256(canonical.encode()).hexdigest() == receipt["seal"]
The seal is a digest, not a public-key signature. It proves that the receipt was not edited after the run wrote it. It does not prove authorship, because this Actor holds no key material.
The webhook
Set webhookUrl and the Actor sends one JSON POST when a check converges, and
one when a check reaches the wait limit. At most two POSTs for each check.
{"event": "convergence-confirmed","runId": "abc123","checkId": "www.example.com/A","domain": "www.example.com","recordType": "A","expected": ["203.0.113.10"],"convergedAt": "2026-09-03T10:04:00+00:00","elapsedSeconds": 240.0,"answers": {"dns.google": ["203.0.113.10"]},"seal": "9f2c...","sealAlgorithm": "sha256"}
The timeout POST carries "event": "cutover-timeout" with waitedSeconds and
the last answer of each resolver.
The URL must be a public HTTPS address. Loopback, private and reserved
addresses are refused. Redirects are not followed. One attempt is made for each
event, and the outcome lands in webhookStatus and webhookError on the
convergence row, so a webhook that is down never hides the result.
Safety and bounds
- HTTPS only, for the resolvers and for the webhook.
- Loopback, private and reserved targets are refused, before the request.
- Redirects are never followed.
- At most 20 checks, 20 resolvers and 20 expected values for each check.
- At most 8 resolver queries run at the same time.
- A resolver response is read under a 64 KiB cap.
- The wait limit is at most 60 minutes and the poll interval at least 30 seconds.
- No secrets, no proxy, no browser and no state between runs.
Verdicts never fail the run
A timeout, a resolver that will not answer, a webhook that refuses the POST and an input that the schema would have rejected are all written to the dataset and to the run status message. The run ends succeeded. A failed run means a real malfunction, so your alerting stays meaningful.
Pricing
This Actor charges for each event.
| Event | Unit | Price | When |
|---|---|---|---|
cutover-watch-start | run | 0.01 | Once, after the input is found valid. |
resolver-poll | one check, at one resolver, in one poll cycle | 0.0015 | Every poll cycle. |
convergence-confirmed | check that converges | 0.05 | With its webhook POST and its receipt. |
A check that has converged leaves the poll list, so it is never charged again.
A check that times out charges no convergence-confirmed event.
What one cutover costs
Three records, the default eight resolvers, converged after five poll cycles of one minute:
| Event | Count | Price | Cost |
|---|---|---|---|
cutover-watch-start | 1 run = 1 | 0.01 | 0.01 |
resolver-poll | 3 checks x 8 resolvers x 5 cycles = 120 | 0.0015 | 0.18 |
convergence-confirmed | 3 checks = 3 | 0.05 | 0.15 |
| Total | 0.34 |
How the size of the watch moves the bill
| Checks | Resolvers | Poll cycles | Polls | Uncapped | Charged |
|---|---|---|---|---|---|
| 1 | 8 | 5 | 40 | 0.12 | 0.12 |
| 3 | 8 | 5 | 120 | 0.34 | 0.34 |
| 10 | 8 | 15 | 1200 | 2.31 | 2.31 |
| 20 | 8 | 30 | 4800 | 8.21 | 3.00 |
The default maximum total charge for one run is USD 3.00. The last row is the largest run this Actor can perform: 20 checks that never converge, polled across 8 resolvers for the full 30 minutes. The cap stops the bill there.
To spend less, cut the poll cycles first. The poll interval and the wait limit decide the cycle count, and the cycle count is the largest term in every row above.
Development
uv syncuv run pytestuv run ruff check .