Skip to content
domains hosting renewals registrar godaddy namecheap cloudflare schema email-to-json tutorial

Parse Domain & Hosting Renewal Emails to JSON

Turn GoDaddy, Namecheap, Cloudflare, Squarespace, and AWS Route 53 renewal emails into typed JSON — registrar, domain, registration period, amount, currency, expiration date, auto-renew, and renewal status. Schema-first tutorial.

MailFrame Team

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 domainregistrar_name and registrar_domain identify the billing party (GoDaddy.com, LLC; Namecheap, Inc.; Cloudflare, Inc.), and domain_name identifies the specific domain that was renewed. Lower-cased, no leading www., exactly as the registrar prints it.
  • The registration periodregistration_period_years captures 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 datesprevious_expiration_date and new_expiration_date are 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 chargeamount_cents and currency. Storing the amount in minor units avoids floating-point drift across dozens of currencies.
  • The auto-renew stateauto_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: a disabled auto-renew on a production domain is a regression risk.
  • The privacy protection stateprivacy_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 statusrenewed | payment_failed | expired | transferred | cancelled | changed. This is the single most useful field for triage: a payment_failed deserves a pager (a failed renewal can cascade into domain expiration and service downtime within hours for production domains), an expired notice means recovery may now require a redemption fee, and a transferred notice means the domain left the registrar entirely.
  • The payment methodpayment_method_brand normalized (visa, mastercard, amex) and payment_method_last4 (4242). Useful for catching renewals charged to a card that is about to expire.

The fields worth extracting are roughly:

FieldWhy you want it
registrar_name / registrar_domainGroup spend by billing party for the annual rollup
domain_nameJoin key across the lifetime of the domain
registration_period_yearsCompute total years paid for in the year
previous_expiration_date / new_expiration_date”What’s expiring in the next 30 days?”
amount_cents + currencySum actual charges without floating-point error
auto_renewCatch disabled auto-renews on production domains
statusPage on payment_failed or expired, archive on transferred or cancelled
privacy_protectionAudit 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:

  1. Trigger a renewal on a test domain (most registrars let you manually renew a domain from the dashboard without changing the expiration date).
  2. Open the renewal email in your email client and choose “Show original” or “Download .eml” — this gives you the raw RFC 822 MIME source.
  3. 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 the data object 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 — a disabled value here, even with status: "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:

  1. 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.
  2. Alert on auto_renew: disabled for production domains. A disabled auto-renew on a critical domain is a regression risk. Surface these to ops even when status: renewed is 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.
  3. Surface privacy_protection: disabled for compliance review. A domain whose WHOIS privacy has lapsed is exposing registrant contact data. Surface to the security/compliance channel for review.
  4. Roll up amount_cents by 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.
  5. 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.
  6. 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:

  • GoDaddyregistrar_name: "GoDaddy.com, LLC", period in years, new expiration, auto-renew, privacy protection, amount, card, invoice link.
  • Namecheapregistrar_name: "Namecheap, Inc.", period in years, new expiration, auto-renew, WhoisGuard status, amount, card, invoice link.
  • Cloudflareregistrar_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_protection is unknown for these emails.
  • Squarespaceregistrar_name: "Squarespace Domains LLC", period in years, new expiration, auto-renew, amount, card, manage link.
  • AWS Route 53registrar_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_name and registrar_domain carry 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

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/parse as shown above.

Turn your inbox into an API

MailFrame parses raw email into typed JSON against your own schema — PDF and image input are planned. Request early developer access.

Request early access