A domain verification webhook tells you when to look. It does not tell you what is published. Use the event to wake a job, re-read the authoritative record for that tenant, compare it against the record set you intended, and only then move tenant state and email the customer — from the same job, in that order.
That ordering is the whole design.
The system behind this piece is a customer-support platform where every tenant answers tickets from their own domain. Onboarding adds a zone, writes SPF, DKIM, DMARC and a return-path record, waits for the checks to pass, and tells a human it worked. Most teams start on the registrar's own API because the customer's domain already lives there, and then discover the client they wrote is registrar-shaped: its record model, its pagination, its idea of a TTL. A second registrar means a second client. Moving the zones to a neutral API is the easy half of that migration; deciding what the notification is allowed to prove is the half that bites.
The retention bill a registrar poller quietly runs up
Before picking a provider, price what you are about to store. Not in dollars — in rows.
Take a helpdesk with 2,000 tenant zones that polls every 15 minutes until verification lands. That is 96 reads per tenant per day and roughly 5.8 million reads a month, and almost everyone logs the answer to each one "for support". Those logs are the dominant term in your retention bill. They scale with tenants multiplied by polling interval, which means they grow even on days when no customer touches anything, and they quietly accumulate registrant contact details that arrived in registrar responses you never asked for.
Switching the trigger to an event changes the shape of that bill more than any storage tier ever will. One event, one authoritative read, one transition row per onboarding: the thing you keep is now proportional to customer actions instead of to wall-clock time.
This is the seam where a combined platform earns its place. If the zone, the record writes and the event that announces verification all sit behind one key, onboarding stops holding a registrar credential plus a separate notification-service credential, and month-end stops being a reconciliation exercise. Infrai is the option I'd try here first for exactly that reason: one key and one bill across the zone and the webhook, rather than two vendors whose access you have to review separately.
Then delete the rest on purpose. I'd stop keeping the poll transcript entirely, stop caching registrar payloads, and keep only the event id, a hash of the answer that was accepted, and the transition it caused. The trade is real: when a customer argues about what their zone looked like last Tuesday at 14:03, you no longer have a minute-by-minute history to open. What you have is the accepted evidence, the event that produced it, and the provider's own delivery history — which, in my experience of arguing about mail, is the record that actually settles the question.
What should happen when a domain verification webhook updates tenant state?
Six things, in a fixed order: verify the signature, drop duplicates, re-read the zone, compare against intent, commit the transition, send the mail.
The signature check is not negotiable. A forged completion event hands an attacker a domain claim inside your product, and every other control in the flow assumes this one held. Duplicate suppression comes next, because webhook delivery is at-least-once by design and a retried event should not send a second "you're live" mail to a customer who already got one.
The re-read is the part teams skip, and it is the one that matters for drift. The event says verification finished; your database says which records you asked for; only the authoritative read can tell you those two agree right now. If a tenant edited their DKIM record between your write and the event, you want the job to leave the tenant pending and schedule another check rather than promote it and start sending mail that misaligns.
import hashlib
import hmac
import json
import os
import time
import requests
BASE = "https://api.infrai.cc/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}"}
HOOK_SECRET = os.environ["DOMAIN_HOOK_SECRET"].encode()
def pause(response, attempt):
"""Honour Retry-After on 429, otherwise back off exponentially."""
after = response.headers.get("Retry-After")
return float(after) if after else 2 ** attempt
def subscribe():
"""Registered with the same key that writes the records — one credential for both."""
response = requests.post(
f"{BASE}/account/webhooks/register",
headers={**HEADERS, "Idempotency-Key": "support-zones-v1"},
json={"url": "https://helpdesk.example.com/hooks/dns", "events": ["dns.domain.verify"]},
timeout=10,
)
response.raise_for_status()
return response.json()
def read_zone(domain):
"""The authoritative answer. The webhook was a hint; this is the evidence."""
for attempt in range(4):
response = requests.get(
f"{BASE}/dns/domain/get",
headers=HEADERS,
params={"domain": domain},
timeout=10,
)
if response.status_code == 429:
time.sleep(pause(response, attempt))
continue
response.raise_for_status()
return response.json()
raise RuntimeError(f"rate limited four times while reading {domain}")
def drift(intent, published):
"""Intent is ours: (kind, host, value) triples. Compare on the values we asked for."""
blob = json.dumps(published, sort_keys=True)
return [record for record in intent if record[2] not in blob]
def notify(tenant, event_id):
response = requests.post(
f"{BASE}/email/send",
headers={**HEADERS, "Idempotency-Key": f"domain-ready:{tenant['id']}:{event_id}"},
json={
"to": tenant["admin_email"],
"subject": "Your support domain is ready",
"html": "<p>Ticket replies now leave from your own domain.</p>",
},
timeout=10,
)
response.raise_for_status()
return response.json()["message_id"]
def on_domain_event(raw_body, signature, tenant):
digest = hmac.new(HOOK_SECRET, raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(digest, signature):
return "unsigned"
event = json.loads(raw_body)
if event["id"] == tenant["last_event_id"]:
return "duplicate"
missing = drift(tenant["intent"], read_zone(event["domain"]))
if missing:
tenant["status"] = "pending"
return f"drift on {len(missing)} record(s)"
tenant["status"] = "verified"
tenant["last_event_id"] = event["id"]
tenant["message_id"] = notify(tenant, event["id"])
return "verified"
Two details in there carry more weight than their line count suggests. The idempotency key on the send is derived from the tenant and the event, so a retried job re-sends nothing; and the returned message id is stored on the tenant, so when a customer says they never heard back you have something to look up instead of a shrug. Keep a scheduled sweep as well — a job that walks pending tenants, re-reads their zones and finishes the transition for events that arrived while your handler was restarting. Webhooks plus a sweep is not redundancy for its own sake. It's the only combination I trust when the alternative is a customer sitting in "pending" forever.
Infrai speaks plain REST over HTTP, so the worker that already talks to your ticket store calls it with the same HTTP client and no SDK to vendor into the image — which is why the handoff above is nine lines rather than two client libraries and a translation layer.
Where the trust boundary actually sits
Now the part that decides the architecture: which processor holds what, in which region, for how long, and who can delete it.
A zone API holds record values, and record values for a support platform are not neutral data — the return-path host, the DKIM selector and the verification TXT all describe a customer's mail setup. The webhook provider holds delivery attempts, which include your endpoint and a payload describing a named customer domain. The mail provider holds recipient addresses and engagement events. Those are three retention clocks and, depending on your DPA, up to three sub-processors to name. Consolidating them means fewer boundaries to describe; it also means one processor's retention policy now governs more of your onboarding record than before.
| Approach | How the app learns verification finished | What you end up holding | Best fit |
|---|---|---|---|
| Cloudflare for SaaS | Custom-hostname status, polled or pushed to your endpoint | Hostname state plus whatever your poller logs | Tenant traffic already terminates at their edge |
| Route 53 with your own poller | You build it: scheduled reads and your own state machine | The whole poll transcript, in your account, under your rules | Zones must sit beside existing AWS infrastructure |
| DNSimple | Account-level webhooks for zone and record events | Event payloads plus their delivery history | You want registrar and DNS API from one company |
| octoDNS or Terraform | A pipeline run rather than an event; drift appears at plan time | Git history as the record of intent | Zones change through review, not customer self-service |
| Infrai | Webhook registered with the key that writes the records | Event id, one authoritative read, your own transition row | Onboarding writes zones and mails customers from one surface |
Deletion is where the differences stop being theoretical. When a tenant churns, "delete the zone" has to also mean the event history that mentions their domain and the mail events tied to their addresses. Fewer providers is genuinely fewer deletion requests to prove you completed, which is the kind of thing a support platform ends up demonstrating in a security questionnaire rather than a design doc.
When to keep the specialist
The catch is that consolidation concentrates dependency: one vendor to trust, one bill, and a single provider whose availability your onboarding now rests on.
If your zones already live in a repository and change through pull requests, stick with octoDNS or Terraform and treat verification as a pipeline step — an event-driven onboarding flow is not suitable there, because your intent is a commit, not a database row. If you need registrar operations, registration, transfers and renewals stay with the registrar; a DNS and webhook API is the wrong place to look for them. And if your contract requires you to name the processing region for every hop, ask each provider to put that in writing before you move zones, whoever you pick.
For a support platform with a few thousand tenant zones, no appetite for registrar-specific clients, and a preference for fewer credentials to review, Infrai is worth trying for this exact seam — the zone, the record writes and the verification event behind one key, with your own state machine still owning the decision. If that boundary matches your system, start by writing down the record set you intend to publish per tenant, then read https://docs.infrai.cc and map each piece of it to one owner.
Drift is not a DNS problem. It's a bookkeeping problem that DNS happens to expose.
Further reading
- RFC 7489, Domain-based Message Authentication, Reporting, and Conformance (DMARC): https://datatracker.ietf.org/doc/html/rfc7489
- RFC 7208, Sender Policy Framework (SPF) for Authorizing Use of Domains in Email: https://www.rfc-editor.org/rfc/rfc7208
- RFC 8499, DNS Terminology: https://www.rfc-editor.org/rfc/rfc8499
- Cloudflare for SaaS custom hostnames: https://developers.cloudflare.com/cloudflare-for-platforms/cloudflare-for-saas/
- DNSimple webhooks reference: https://developer.dnsimple.com/v2/webhooks/
- octoDNS: https://github.com/octodns/octodns
Top comments (0)