Removing a churned customer's domain and everything it left
Records first, then the zone, then the email domain — with the retention pause and the one check that stops you deleting a domain still serving traffic.
When a customer leaves, their custom domain leaves three things behind on Infrai: DNS records, the zone itself, and usually an email sending domain attached to it. The order to remove them is records with DELETE /v1/dns/record/delete, then the zone with DELETE /v1/dns/domain/delete, then the sending domain with DELETE /v1/email/domain/delete/{domain}.
Reversed, you get a sending domain whose authentication records have vanished, which looks like a deliverability incident rather than a cancellation.
Look before you delete
The check that prevents the bad day: is anything still resolving through this zone?
curl -sS -G "https://api.infrai.cc/v1/dns/record/list" \
--data-urlencode "zone_id=3a9d545444dc7a02e085889a3f713078" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"records": [
{"record_id": "rec_9wQ1zV6pLkS3dHyB", "zone_id": "3a9d545444dc7a02e085889a3f713078",
"record_type": "CNAME", "name": "portal.northwind.example", "content": "edge.yourapp.example",
"ttl": 300, "priority": null, "proxied": true},
{"record_id": "rec_2fVc8nRqLmT4xBzY", "zone_id": "3a9d545444dc7a02e085889a3f713078",
"record_type": "MX", "name": "northwind.example", "content": "mx.customer-mail.example",
"ttl": 3600, "priority": 10, "proxied": false}
]
}
}
That second record is the one to stop at. An MX pointing somewhere that isn’t you means this zone carries the customer’s mail, and deleting the zone stops their email — for a company that just cancelled a subscription, that is a very expensive mistake.
A zone you added for them and that only contains records you wrote is safe to remove. A zone they delegated wholesale, containing records they depend on, is theirs and should be handed back rather than deleted.
Delete records, then the zone
curl -sS -X DELETE -G "https://api.infrai.cc/v1/dns/record/delete" \
--data-urlencode "zone_id=3a9d545444dc7a02e085889a3f713078" \
--data-urlencode "record_id=rec_9wQ1zV6pLkS3dHyB" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": { "deleted": true, "id": "rec_9wQ1zV6pLkS3dHyB" }
}
curl -sS -X DELETE -G "https://api.infrai.cc/v1/dns/domain/delete" \
--data-urlencode "domain=northwind.example" \
--data-urlencode "zone_id=3a9d545444dc7a02e085889a3f713078" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
Then the sending domain, which is a separate resource on the email side:
curl -sS -X DELETE "https://api.infrai.cc/v1/email/domain/delete/mail.northwind.example" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
The offboarding routine
import os
import requests
API = "https://api.infrai.cc"
KEY = os.environ["INFRAI_API_KEY"]
SESSION = requests.Session()
SESSION.headers.update({"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"})
# Records that suggest the zone serves something other than your product.
FOREIGN_SIGNALS = ("MX",)
def zone_records(zone_id: str) -> list[dict]:
resp = SESSION.get(f"{API}/v1/dns/record/list", params={"zone_id": zone_id}, timeout=25)
resp.raise_for_status()
return resp.json()["data"].get("records", [])
def safe_to_remove(zone_id: str, our_target: str) -> tuple[bool, str]:
"""Refuse to delete a zone that carries somebody else's mail or points
somewhere that isn't us. Cheap check, and the failure it prevents is the kind
that ends up in a legal thread."""
records = zone_records(zone_id)
for record in records:
if record["record_type"] in FOREIGN_SIGNALS:
return False, f"zone carries {record['record_type']} {record['name']} — hand it back, don't delete"
if record["record_type"] in ("A", "AAAA", "CNAME") and our_target not in (record.get("content") or ""):
return False, f"{record['name']} points at {record.get('content')}, not us"
return True, f"{len(records)} record(s), all ours"
def offboard(domain: str, zone_id: str, sending_domain: str | None, our_target: str,
apply: bool = False) -> dict:
ok, why = safe_to_remove(zone_id, our_target)
if not ok:
return {"action": "skipped", "reason": why}
removed = []
for record in zone_records(zone_id):
if apply:
SESSION.delete(f"{API}/v1/dns/record/delete",
params={"zone_id": zone_id, "record_id": record["record_id"]}, timeout=25)
removed.append(f"{record['record_type']} {record['name']}")
if apply:
SESSION.delete(f"{API}/v1/dns/domain/delete",
params={"domain": domain, "zone_id": zone_id}, timeout=30)
if sending_domain:
SESSION.delete(f"{API}/v1/email/domain/delete/{sending_domain}", timeout=30)
return {"action": "removed" if apply else "dry-run", "reason": why,
"records": removed, "zone": domain, "sending_domain": sending_domain}
if __name__ == "__main__":
print(offboard(os.environ["DOMAIN"], os.environ["ZONE_ID"],
os.environ.get("SENDING_DOMAIN"), "edge.yourapp.example",
apply=os.environ.get("APPLY") == "1"))
Don’t do it on cancellation day
Churn reverses more often than anyone plans for, and re-onboarding a custom domain means the customer goes back to their registrar. Give it a retention window.
curl -sS -X POST "https://api.infrai.cc/v1/cron/create" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"name": "offboard-northwind-dns",
"run_at": "2026-10-21T03:00:00Z",
"task": "https://ops.example.com/hooks/offboard-dns?tenant=t_northwind",
"timeout_seconds": 300
}'
run_at schedules a single run rather than a recurrence, so a thirty-day grace period is one call at cancellation time. If the customer comes back, DELETE /v1/cron/delete/{id} cancels it.
| Timing | What to do |
|---|---|
| Cancellation day | stop serving, keep DNS, schedule the removal |
| Day 7 | notify: “your domain configuration will be removed on the 21st” |
| Day 30 | run the routine above |
| Reactivated any time before | delete the scheduled job |
What to keep
Delete the configuration, not the record of it. Keep the zone id, the domain, the dates and what you removed, because “did we ever host this domain” becomes a question during a dispute, and the audit trail belongs somewhere durable — POST /v1/logs/ingest on the same key is enough.
Usage history is kept for you: GET /v1/account/usage still shows what the tenant consumed, so deleting their DNS doesn’t erase the billing story.
Limitations
There’s no bulk delete: no “remove every record in this zone” and no “remove every zone for this tenant”, so the loop above is what you get, and it isn’t transactional — a failure halfway leaves a partially cleaned zone that the next run has to tolerate. That’s an argument for the routine being re-runnable rather than clever.
Nor is there a “handback” operation. If the zone is really the customer’s, the courteous path is to tell them to re-delegate to their own nameservers before you delete anything, because deleting a delegated zone while their domain still points at it takes their whole domain down — and that’s a limitation of DNS rather than of any API. Route 53 and GoDaddy have the same property.
The compensating half is that offboarding touches one credential: DNS, the sending domain, the schedule, the log line and the customer notification via POST /v1/email/send are the same account and one invoice, so there’s no vendor left holding an orphaned resource you forgot you were paying for. DNS and email domain routes report billing_class: free in discovery (verified 2026-09-21), and platform rates drift downward over time.