If you manage more than a handful of domains, your inbox carries the renewal calendar for every one of them — GoDaddy, Namecheap, Cloudflare, Squarespace, AWS Route 53, Hover, Porkbun, Google Domains (now Squarespace), OVH, Gandi, and every other registrar or hosting provider you have ever pointed a nameserver at. Each one charges on its own schedule, sends its own renewal notice with its own layout, and prints the same handful of fields in slightly different words: which registrar, which domain, how many years were added to the registration, how much was charged, in which currency, when the new expiration date lands, whether auto-renew is on, and whether the charge actually went through. After a few years the renewal pile runs into the dozens per quarter, and the questions “what is expiring in the next 30 days?”, “which domains are still on auto-renew?”, and “what did I actually pay for renewals last year?” turn into a Friday-afternoon grep exercise across five inboxes and three spreadsheets.
This post shows how to turn those emails into typed, validated JSON you can route to a domain-portfolio dashboard, a whois-refresh queue, or a “domain expiring soon” pager — using a JSON Schema and a single POST to MailFrame’s /v1/parse endpoint. If you want the ready-made schema reference, it lives at Parse Domain & Hosting Renewal Emails; this post is the why-and-how around it.
What’s actually in a domain renewal email
The signal you want from a renewal email is not the friendly “thanks for being a customer” prose — it is a small, structured set of fields every registrar prints somewhere in the message, almost always in the same order:
- The registrar and the domain —
registrar_nameandregistrar_domainidentify the billing party (GoDaddy.com, LLC; Namecheap, Inc.; Cloudflare, Inc.), anddomain_nameidentifies the specific domain that was renewed. Lower-cased, no leadingwww., exactly as the registrar prints it. - The registration period —
registration_period_yearscaptures how many years were added to the registration in this cycle (most registrars default to 1 year, but some offer 2, 3, 5, or 10-year terms at a discount). - The expiration dates —
previous_expiration_dateandnew_expiration_dateare the join keys for downstream alerting. Some registrars print only the new date; MailFrame computes the previous date from the new date minus the period so you can use either as the source of truth. - The charge —
amount_centsandcurrency. Storing the amount in minor units avoids floating-point drift across dozens of currencies. - The auto-renew state —
auto_renew(enabled | disabled | unknown) tells you whether the next cycle is automatic or whether the domain will lapse if you do nothing. This is the single most useful field for portfolio hygiene: adisabledauto-renew on a production domain is a regression risk. - The privacy protection state —
privacy_protection(enabled | disabled | unknown) captures WHOIS privacy / proxy status when the registrar prints it, so you can audit which domains are exposing registrant contact data. - The status —
renewed | payment_failed | expired | transferred | cancelled | changed. This is the single most useful field for triage: apayment_faileddeserves a pager (a failed renewal can cascade into domain expiration and service downtime within hours for production domains), anexpirednotice means recovery may now require a redemption fee, and atransferrednotice means the domain left the registrar entirely. - The payment method —
payment_method_brandnormalized (visa,mastercard,amex) andpayment_method_last4(4242). Useful for catching renewals charged to a card that is about to expire.
The fields worth extracting are roughly:
| Field | Why you want it |
|---|---|
registrar_name / registrar_domain | Group spend by billing party for the annual rollup |
domain_name | Join key across the lifetime of the domain |
registration_period_years | Compute total years paid for in the year |
previous_expiration_date / new_expiration_date | ”What’s expiring in the next 30 days?” |
amount_cents + currency | Sum actual charges without floating-point error |
auto_renew | Catch disabled auto-renews on production domains |
status | Page on payment_failed or expired, archive on transferred or cancelled |
privacy_protection | Audit which domains are exposing registrant data |
A JSON Schema for the extraction
MailFrame is schema-first: you declare the exact shape you want before anything is parsed, and every extraction is validated against that contract before it is returned. (For the reasoning behind that design, see Why We Built MailFrame Schema-First.)
The full JSON Schema definition (every field, every enum, every constraint) lives at Parse Domain & Hosting Renewal Emails. That page is the source of truth — if you copy a schema into your own codebase, prefer the version there. Below is a summary of the fields the extraction returns so the rest of this tutorial makes sense without flipping tabs.
Step-by-step: parsing a real GoDaddy renewal
This walkthrough parses a representative GoDaddy renewal email end to end. The same flow works for Namecheap, Cloudflare, Squarespace, AWS Route 53, Hover, Porkbun, and every other registrar that prints the renewal fields in plain text.
1. Get the raw email content
To test the parser, you need the raw content of a domain renewal email. The easiest way to get one:
- Trigger a renewal on a test domain (most registrars let you manually renew a domain from the dashboard without changing the expiration date).
- Open the renewal email in your email client and choose “Show original” or “Download .eml” — this gives you the raw RFC 822 MIME source.
- Save the content to a file, for example
renewal.eml.
If you do not have a real renewal handy, here is a representative sample you can use for testing:
From: noreply@godaddy.com
Subject: acmewidgets.com has been renewed
Date: Sun, 12 Jul 2026 09:02:11 -0700
To: jordan.rivera@example.com
Hi Jordan,
Your domain acmewidgets.com has been renewed successfully.
Registration period: 1 year
New expiration date: July 15, 2027
Auto-renew: Enabled
Privacy protection: Enabled
Amount charged: $19.99 USD
Payment method: Visa ending in 4242
Invoice: INV-2026-7711
Manage your domain:
https://dcc.godaddy.com/manage/acmewidgets.com
View invoice:
https://dcc.godaddy.com/billing/invoice/INV-2026-7711
2. Send the email to MailFrame
With your API key set as an environment variable and the email content in a file, make the parse request:
export MAILFRAME_API_KEY="mf_live_xxxxxxxxxxxxxxxx"
curl https://api.mailframe.ai/v1/parse \
-H "Authorization: Bearer $MAILF..._KEY" \
-H "Content-Type: application/json" \
-d '{
"schema_id": "domain_hosting_renewal",
"raw_mime": "From: noreply@godaddy.com\r\nTo: jordan.rivera@example.com\r\nSubject: acmewidgets.com has been renewed\r\nDate: Sun, 12 Jul 2026 09:02:11 -0700\r\n\r\nHi Jordan,\r\n\r\nYour domain acmewidgets.com has been renewed successfully.\r\n\r\nRegistration period: 1 year\r\nNew expiration date: July 15, 2027\r\nAuto-renew: Enabled\r\nPrivacy protection: Enabled\r\n\r\nAmount charged: $19.99 USD\r\nPayment method: Visa ending in 4242\r\nInvoice: INV-2026-7711\r\n\r\nManage your domain:\r\nhttps://dcc.godaddy.com/manage/acmewidgets.com\r\n\r\nView invoice:\r\nhttps://dcc.godaddy.com/billing/invoice/INV-2026-7711"
}'
If you saved the email to a file, build the payload with jq:
jq -Rs '{schema_id: "domain_hosting_renewal", raw_mime: .}' < renewal.eml | \
curl https://api.mailframe.ai/v1/parse \
-H "Authorization: Bearer $MAILF..._KEY" \
-H "Content-Type: application/json" \
-d @-
3. Read the response
A successful parse returns HTTP 200 with the typed data:
{
"id": "parse_2c9e4f",
"status": "completed",
"validation_errors": [],
"data": {
"registrar_name": "GoDaddy.com, LLC",
"registrar_domain": "godaddy.com",
"domain_name": "acmewidgets.com",
"registration_period_years": 1,
"previous_expiration_date": "2026-07-15",
"new_expiration_date": "2027-07-15",
"amount_cents": 1999,
"currency": "usd",
"auto_renew": "enabled",
"status": "renewed",
"privacy_protection": "enabled",
"payment_method_brand": "visa",
"payment_method_last4": "4242",
"manage_url": "https://dcc.godaddy.com/manage/acmewidgets.com",
"invoice_url": "https://dcc.godaddy.com/billing/invoice/INV-2026-7711",
"account_email": "jordan.rivera@example.com",
"date": "2026-07-12"
}
}
Key things to check in the response:
status—"completed"means the extraction ran and the result passed schema validation. Other values include"failed"(the extraction itself errored).validation_errors— an empty array means every required field passed validation. If any required field is missing or a type does not match, the errors are listed here — but thedataobject is still returned so you can inspect the partial result.data— the typed JSON payload, already validated against the schema. You can write it straight to your database without additional type checks.auto_renew— adisabledvalue here, even withstatus: "renewed", means the next cycle will not auto-charge. Surface these to ops so auto-renew can be re-enabled before the next expiration date.
4. Handle the response in your application
Here is how to integrate the parse result into a typical domain-portfolio tracker:
TypeScript
// Replace with your real pager client (PagerDuty, opsgenie, etc.).
const pager = { trigger: async (_alert: unknown) => {} };
const res = await fetch("https://api.mailframe.ai/v1/parse", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.MAILFRAME_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
schema_id: "domain_hosting_renewal",
raw_mime: rawEmailText,
}),
});
if (!res.ok) {
throw new Error(`MailFrame error ${res.status}: ${await res.text()}`);
}
const { data, status, validation_errors } = await res.json();
if (status === "completed" && validation_errors.length === 0) {
// Page on payment_failed or expired — these cascade fast.
if (data.status === "payment_failed") {
await pager.trigger({
severity: "high",
domain: data.domain_name,
registrar: data.registrar_name,
message: `Domain renewal payment failed: ${data.domain_name}`,
});
}
if (data.auto_renew === "disabled") {
await pager.trigger({
severity: "medium",
domain: data.domain_name,
registrar: data.registrar_name,
message: `Auto-renew disabled on ${data.domain_name}; new expiration ${data.new_expiration_date}`,
});
}
// Write straight to your database — already typed and validated.
await db.domainRenewals.insert({
registrarName: data.registrar_name,
registrarDomain: data.registrar_domain,
domainName: data.domain_name,
registrationPeriodYears: data.registration_period_years,
previousExpirationDate: data.previous_expiration_date,
newExpirationDate: data.new_expiration_date,
grossCents: data.amount_cents,
currency: data.currency,
autoRenew: data.auto_renew,
status: data.status,
privacyProtection: data.privacy_protection,
cardBrand: data.payment_method_brand,
cardLast4: data.payment_method_last4,
renewalDate: data.date,
});
}
Python
import os
import requests
from datetime import datetime, timedelta
# Replace with your real pager client (PagerDuty, opsgenie, etc.).
def pager_trigger(*_a, **_k):
pass
MAILFRAME_API_KEY = os.environ["MAILFRAME_API_KEY"]
res = requests.post(
"https://api.mailframe.ai/v1/parse",
headers={"Authorization": f"Bearer {MAILFRAME_API_KEY}"},
json={
"schema_id": "domain_hosting_renewal",
"raw_mime": raw_email_text,
},
timeout=30,
)
res.raise_for_status()
body = res.json()
if body["status"] == "completed" and not body["validation_errors"]:
data = body["data"]
# Alert on payment_failed: domain expiration is imminent.
if data["status"] == "payment_failed":
pager_trigger(
severity="high",
domain=data["domain_name"],
registrar=data["registrar_name"],
message=f"Renewal failed: {data['domain_name']}",
)
# Alert on disabled auto-renew for any production domain.
if data["auto_renew"] == "disabled":
pager_trigger(
severity="medium",
domain=data["domain_name"],
message=f"Auto-renew off; expires {data['new_expiration_date']}",
)
db.execute(
"""
INSERT INTO domain_renewals
(registrar, domain, period_years, prev_expires, new_expires,
amount_cents, currency, auto_renew, status, renewed_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (domain, new_expires) DO NOTHING
""",
(
data["registrar_name"],
data["domain_name"],
data["registration_period_years"],
data["previous_expiration_date"],
data["new_expiration_date"],
data["amount_cents"],
data["currency"],
data["auto_renew"],
data["status"],
data["date"],
),
)
5. Add new vendors by extending the schema
The base schema handles GoDaddy, Namecheap, Cloudflare, Squarespace, AWS
Route 53, Hover, Porkbun, OVH, and Gandi out of the box. To add a vendor
the base schema does not recognize, supply an inline JSON Schema on the
request instead of using schema_id — MailFrame validates against
whatever shape you send. See Building a JSON Schema Email
Parser for the design rules, and
Parse Subscription Renewal Emails
for a side-by-side comparison of how the SaaS-renewal schema handles
multiple vendors.
Operational playbook
Once the parser is in production, the workflow that actually earns its keep is the alerting layer:
- Page on
payment_failed. Treat it as a high-priority signal. A failed renewal can cascade into domain expiration and service downtime within hours for production domains. Pager rotation belongs on the ops channel, not the renewals inbox. - Alert on
auto_renew: disabledfor production domains. A disabled auto-renew on a critical domain is a regression risk. Surface these to ops even whenstatus: renewedis set, so the team can re-enable auto-renew before the next cycle ends. The cheapest version of this alert is a Slack message; the most useful version links straight to the registrar’s “enable auto-renew” page. - Surface
privacy_protection: disabledfor compliance review. A domain whose WHOIS privacy has lapsed is exposing registrant contact data. Surface to the security/compliance channel for review. - Roll up
amount_centsby registrar annually. Annual rollups let you negotiate volume pricing and catch pricing drift — if a registrar silently raised renewal prices by 20% last year, the rollup is where you spot it. - Dedup on
(domain_name, new_expiration_date). A single domain renewal can produce two or three emails (initial confirmation, optional receipt when the invoice closes, optional reminder a few days ahead). Grouping on(domain_name, new_expiration_date)keeps a long-lived domain from creating multiple rows per cycle. - Whois-refresh on
new_expiration_date. Once a renewal is parsed, push the new expiration date into your whois / DNS inventory so the operational source of truth stays in sync with the registrar. Drift between the two is what causes “wait, when did this domain expire?” emergencies.
What this looks like across vendors
The same schema produces a normalized JSON object regardless of which registrar sent the email. Here is the side-by-side shape:
- GoDaddy —
registrar_name: "GoDaddy.com, LLC", period in years, new expiration, auto-renew, privacy protection, amount, card, invoice link. - Namecheap —
registrar_name: "Namecheap, Inc.", period in years, new expiration, auto-renew, WhoisGuard status, amount, card, invoice link. - Cloudflare —
registrar_name: "Cloudflare, Inc.", period in years, new expiration, auto-renew, amount, card, receipt link. Cloudflare does not sell privacy protection as an add-on;privacy_protectionisunknownfor these emails. - Squarespace —
registrar_name: "Squarespace Domains LLC", period in years, new expiration, auto-renew, amount, card, manage link. - AWS Route 53 —
registrar_name: "Amazon Web Services, Inc.", period in years, new expiration, auto-renew, amount, card, invoice link. AWS often wraps the domain renewal inside a longer billing notification; the schema targets the renewal line item. - Hover / Porkbun / OVH / Gandi — same shape;
registrar_nameandregistrar_domaincarry the billing party, the rest of the fields normalize to the same JSON keys.
A payment_failed from any of these vendors routes to the same pager, a
disabled auto-renew routes to the same Slack channel, and an annual
rollup by registrar_name works without any vendor-specific code.
Why schema-first matters here
Rule-based parsers — regex templates per vendor, no-code highlighters,
or LLM prompts that ask the model to “return JSON with these fields” —
silently mislabel fields when a vendor changes their layout. The most
common failure mode is a successful parse with auto_renew: "enabled"
when the email actually said “auto-renew has been disabled” — the
model picks the easier field and the operator never knows. Schema-first
parsing turns that failure mode into a hard failure: the
auto_renew enum constraint rejects anything outside
{"enabled", "disabled", "unknown"}, and the validation error surfaces
in the response so you can decide whether to retry, alert, or file the
extraction for review. (For the deeper design rationale, see
Why We Built MailFrame Schema-First
and Designing a Reliable Schema-First Email Parsing
Pipeline.)
Where to go next
- The full schema reference, including every field and the JSON Schema definition: Parse Domain & Hosting Renewal Emails.
- The reference for SaaS billing renewals from Vercel, Netlify, OpenAI, Notion, and others: Parse Subscription Renewal Emails.
- The reference for AP-side vendor billing: Parse Invoice & Payment-Due Emails.
- The full schema library: /parse/.
- API quickstart with curl, TypeScript, and Python examples: /docs/.
- Free tier limits and pricing: /pricing/.
Body-only today. MailFrame reads the fields printed in the email body. PDF, image, and calendar-attachment input are on the roadmap; inbox forwarding — pointing your registrar’s notification forwarding at a unique inbox address MailFrame assigns you — is planned as well. Until those ship, POST the raw email to
/v1/parseas shown above.