# DNS Cutover Convergence Gate with Webhook Receipt (`kingii98/dns-cutover-convergence-gate-with-webhook-receipt`) Actor

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

- **URL**: https://apify.com/kingii98/dns-cutover-convergence-gate-with-webhook-receipt.md
- **Developed by:** [kingii98](https://apify.com/kingii98) (community)
- **Categories:**
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $10.00 / 1,000 cutover watch starts

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.
Actors are written with capital "A".

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## DNS Cutover Convergence Gate with Webhook Receipt

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:

```json
{
  "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.

```json
{
  "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".

```json
{
  "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:

```python
import hashlib, json
sealed = {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.

```json
{
  "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

```bash
uv sync
uv run pytest
uv run ruff check .
```

# Actor input Schema

## `checks` (type: `array`):

1 to 20 items. Each item holds domain (the fully qualified name), record\_type (A, AAAA, CNAME, MX, TXT or NS) and expected (the value or the list of values that the record must hold once the cutover is complete). A check converges only when the answer set is exactly the expected set, so a resolver that still serves the old value beside the new one does not count. Write an MX value as "10 mail.example.com". Write a TXT value without the surrounding quotes.

## `resolvers` (type: `array`):

1 to 20 HTTPS URLs. Each one must answer an RFC 8484 wire-format GET request. The default set holds 8 public resolvers. A URL that resolves to a loopback, private or reserved address is skipped and the reason is written to the log.

## `pollIntervalSeconds` (type: `integer`):

The wait between two poll cycles. The minimum is 30 seconds, so the resolvers are not overloaded.

## `maxWaitMinutes` (type: `integer`):

The run stops at this limit and reports a timeout. A timeout is a normal result and the run still succeeds.

## `requireAllResolvers` (type: `boolean`):

Keep this on for a cutover gate: every resolver in the list must serve the expected value. Switch it off to converge as soon as one resolver agrees.

## `webhookUrl` (type: `string`):

Optional public HTTPS URL. One JSON POST goes out when a check converges and one goes out when a check reaches the wait limit. Chain your next cutover step onto it. Loopback, private and reserved addresses are refused. Leave it empty to read the result in the dataset only.

## `timeoutSeconds` (type: `integer`):

Timeout for one resolver query and for one webhook POST.

## Actor input object example

```json
{
  "checks": [
    {
      "domain": "one.one.one.one",
      "record_type": "A",
      "expected": [
        "1.0.0.1",
        "1.1.1.1"
      ]
    },
    {
      "domain": "dns.google",
      "record_type": "A",
      "expected": [
        "8.8.4.4",
        "8.8.8.8"
      ]
    },
    {
      "domain": "dns.google",
      "record_type": "AAAA",
      "expected": [
        "2001:4860:4860::8844",
        "2001:4860:4860::8888"
      ]
    }
  ],
  "resolvers": [
    "https://cloudflare-dns.com/dns-query",
    "https://dns.google/dns-query",
    "https://dns.quad9.net/dns-query",
    "https://unfiltered.adguard-dns.com/dns-query",
    "https://doh.opendns.com/dns-query",
    "https://dns.mullvad.net/dns-query",
    "https://doh.sb/dns-query",
    "https://dns.nextdns.io/dns-query"
  ],
  "pollIntervalSeconds": 60,
  "maxWaitMinutes": 30,
  "requireAllResolvers": true,
  "webhookUrl": "",
  "timeoutSeconds": 10
}
```

# Actor output Schema

## `dataset` (type: `string`):

No description

## `convergence` (type: `string`):

No description

## `receipts` (type: `string`):

No description

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "checks": [
        {
            "domain": "one.one.one.one",
            "record_type": "A",
            "expected": [
                "1.0.0.1",
                "1.1.1.1"
            ]
        },
        {
            "domain": "dns.google",
            "record_type": "A",
            "expected": [
                "8.8.4.4",
                "8.8.8.8"
            ]
        },
        {
            "domain": "dns.google",
            "record_type": "AAAA",
            "expected": [
                "2001:4860:4860::8844",
                "2001:4860:4860::8888"
            ]
        }
    ],
    "resolvers": [
        "https://cloudflare-dns.com/dns-query",
        "https://dns.google/dns-query",
        "https://dns.quad9.net/dns-query",
        "https://unfiltered.adguard-dns.com/dns-query",
        "https://doh.opendns.com/dns-query",
        "https://dns.mullvad.net/dns-query",
        "https://doh.sb/dns-query",
        "https://dns.nextdns.io/dns-query"
    ],
    "pollIntervalSeconds": 60,
    "maxWaitMinutes": 30,
    "requireAllResolvers": true,
    "webhookUrl": "",
    "timeoutSeconds": 10
};

// Run the Actor and wait for it to finish
const run = await client.actor("kingii98/dns-cutover-convergence-gate-with-webhook-receipt").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "checks": [
        {
            "domain": "one.one.one.one",
            "record_type": "A",
            "expected": [
                "1.0.0.1",
                "1.1.1.1",
            ],
        },
        {
            "domain": "dns.google",
            "record_type": "A",
            "expected": [
                "8.8.4.4",
                "8.8.8.8",
            ],
        },
        {
            "domain": "dns.google",
            "record_type": "AAAA",
            "expected": [
                "2001:4860:4860::8844",
                "2001:4860:4860::8888",
            ],
        },
    ],
    "resolvers": [
        "https://cloudflare-dns.com/dns-query",
        "https://dns.google/dns-query",
        "https://dns.quad9.net/dns-query",
        "https://unfiltered.adguard-dns.com/dns-query",
        "https://doh.opendns.com/dns-query",
        "https://dns.mullvad.net/dns-query",
        "https://doh.sb/dns-query",
        "https://dns.nextdns.io/dns-query",
    ],
    "pollIntervalSeconds": 60,
    "maxWaitMinutes": 30,
    "requireAllResolvers": True,
    "webhookUrl": "",
    "timeoutSeconds": 10,
}

# Run the Actor and wait for it to finish
run = client.actor("kingii98/dns-cutover-convergence-gate-with-webhook-receipt").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "checks": [
    {
      "domain": "one.one.one.one",
      "record_type": "A",
      "expected": [
        "1.0.0.1",
        "1.1.1.1"
      ]
    },
    {
      "domain": "dns.google",
      "record_type": "A",
      "expected": [
        "8.8.4.4",
        "8.8.8.8"
      ]
    },
    {
      "domain": "dns.google",
      "record_type": "AAAA",
      "expected": [
        "2001:4860:4860::8844",
        "2001:4860:4860::8888"
      ]
    }
  ],
  "resolvers": [
    "https://cloudflare-dns.com/dns-query",
    "https://dns.google/dns-query",
    "https://dns.quad9.net/dns-query",
    "https://unfiltered.adguard-dns.com/dns-query",
    "https://doh.opendns.com/dns-query",
    "https://dns.mullvad.net/dns-query",
    "https://doh.sb/dns-query",
    "https://dns.nextdns.io/dns-query"
  ],
  "pollIntervalSeconds": 60,
  "maxWaitMinutes": 30,
  "requireAllResolvers": true,
  "webhookUrl": "",
  "timeoutSeconds": 10
}' |
apify call kingii98/dns-cutover-convergence-gate-with-webhook-receipt --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,kingii98/dns-cutover-convergence-gate-with-webhook-receipt"
        }
    }
}

```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/q1vYd1ZUJGRjNR0jO/builds/VPL7a5N9mHatrgpAC/openapi.json
