If you run any kind of modern software stack, your inbox is full of subscription renewal emails — Vercel Pro, Notion, Linear, Figma, Datadog, OpenAI, Netlify, GitHub, Slack, every monitoring tool and every CI vendor you have ever signed up for. 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: what subscription, on which product, on which plan, for how much, on which card, on which date, and whether the charge actually went through. After a year the email pile runs into the hundreds, and the question “what is renewing this month and what is it costing us?” turns 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 renewals dashboard, a finance-ops queue, or a payment-failure 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 Subscription Renewal Emails; this post is the why-and-how around it.
What’s actually in a SaaS 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 vendor prints somewhere in the message, and almost always in the same order:
- The subscription identity —
subscription_idfrom the vendor (Vercel writessub_1PqL2xK9, Stripe writessub_..., Netlify writes a UUID). This is the join key across renewals, plan changes, cancellations, and payment failures over the lifetime of the subscription. - The product and plan —
product_nameis the thing you are paying for (Vercel Pro,Notion Plus,Linear Standard);plan_nameis the tier on it (Pro,Team,Business,Starter). Some vendors collapse these into one label; MailFrame separates them so you can group by product and slice by plan independently. - The billing terms —
billing_interval(monthly | quarterly | annual | weekly) andamount_centspluscurrency. Storing the amount in minor units avoids floating-point drift across dozens of currencies. - The renewal date —
renewal_date(and the aliasnext_renewal_date, because vendors phrase this differently — “next billing date”, “will renew on”, “your subscription renews on”). Both keys are populated from the same source value. - The payment method —
payment_method_brandnormalized (visa,mastercard,amex) andpayment_method_last4(4242). Useful for flagging renewals charged to a card that is about to expire. - The status —
renewed | trial_ending | payment_failed | cancelled | changed. This is the single most useful field for triage: apayment_faileddeserves a pager; atrial_endingdeserves a heads-up; acancelleddeserves an archival update.
The fields worth extracting are roughly:
| Field | Why you want it |
|---|---|
subscription_id | Join key across the lifetime of a subscription |
vendor_name / vendor_domain | Group renewals by billing party for spend rollups |
product_name / plan_name | Slice spend by product and by tier |
billing_interval | Compute annualized cost (monthly × 12, quarterly × 4, etc.) |
amount_cents + currency | Sum actual charges without floating-point error |
renewal_date | ”What’s renewing this month?” |
payment_method_brand + last4 | Catch renewals on cards about to expire |
status | Page on payment_failed, archive on cancelled, flag on trial_ending |
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.)
Here is a compact schema for SaaS subscription renewal emails. The enum constraints on status and billing_interval are not hints — they are hard constraints, so a value outside the list fails validation instead of silently landing in your renewals database:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "subscription_renewal",
"type": "object",
"required": [
"subscription_id",
"product_name",
"vendor_name",
"billing_interval",
"amount_cents",
"currency",
"renewal_date",
"status"
],
"properties": {
"subscription_id": { "type": "string", "minLength": 1 },
"product_name": { "type": "string", "minLength": 1 },
"vendor_name": { "type": "string", "minLength": 1 },
"vendor_domain": { "type": "string", "format": "hostname" },
"plan_name": { "type": "string" },
"billing_interval": {
"type": "string",
"enum": ["monthly", "quarterly", "annual", "weekly"]
},
"amount_cents": { "type": "integer", "minimum": 0 },
"currency": { "type": "string", "minLength": 3, "maxLength": 3 },
"renewal_date": { "type": "string", "format": "date" },
"next_renewal_date": { "type": "string", "format": "date" },
"payment_method_brand": { "type": "string" },
"payment_method_last4": { "type": "string", "pattern": "^[0-9]{4}$" },
"status": {
"type": "string",
"enum": ["renewed", "trial_ending", "payment_failed", "cancelled", "changed"]
},
"manage_url": { "type": "string", "format": "uri" },
"invoice_url": { "type": "string", "format": "uri" },
"account_email": { "type": "string", "format": "email" },
"date": { "type": "string", "format": "date" }
}
}
This is the same schema MailFrame ships pre-built under the id subscription_renewal, so you do not have to register it yourself — but copying it here is a good starting point if you want to tighten or extend it for your own routing logic (for example, require payment_method_last4 if every renewal in your workflow must carry a card fingerprint).
Send the raw email to /v1/parse
MailFrame’s shipped path is direct and synchronous: you POST the raw RFC 822 MIME text of the email — exactly the bytes you received — along with a schema id, and you get typed JSON back in the same HTTP response. No inbox to configure, no callback to wait on.
You can reference the pre-built schema by id (schema_id) or pass an inline JSON Schema. Here it is with curl, using the built-in subscription_renewal schema:
curl -X POST https://api.mailframe.ai/v1/parse \
-H "Authorization: Bearer $MAILF...KEY" \
-H "Content-Type: application/json" \
-d '{
"schema_id": "subscription_renewal",
"raw_mime": "From: billing@vercel.com\r\nTo: jordan.rivera@example.com\r\nSubject: Your Vercel Pro plan has been renewed\r\nDate: Tue, 14 Jul 2026 09:14:00 -0400\r\n\r\nHi Jordan,\r\n\r\nYour Vercel Pro plan has been renewed. Your subscription will renew again on Aug 14, 2026.\r\n\r\nSubscription: Vercel Pro\r\nPlan: Pro\r\nBilling cycle: Monthly\r\nAmount: $20.00 USD\r\nPayment method: Visa ending in 4242\r\nSubscription ID: sub_1PqL2xK9\r\n\r\nManage your subscription:\r\nhttps://vercel.com/dashboard/billing/subscriptions/sub_1PqL2xK9\r\n\r\nView your invoice:\r\nhttps://vercel.com/dashboard/billing/invoices/inv_2026_07"
}'
The response wraps the extracted, schema-valid data in a data object alongside the parse status and any validation errors:
{
"id": "parse_7c4b21",
"status": "completed",
"validation_errors": [],
"data": {
"subscription_id": "sub_1PqL2xK9",
"product_name": "Vercel Pro",
"vendor_name": "Vercel Inc.",
"vendor_domain": "vercel.com",
"plan_name": "Pro",
"billing_interval": "monthly",
"amount_cents": 2000,
"currency": "usd",
"renewal_date": "2026-08-14",
"next_renewal_date": "2026-08-14",
"payment_method_brand": "visa",
"payment_method_last4": "4242",
"status": "renewed",
"manage_url": "https://vercel.com/dashboard/billing/subscriptions/sub_1PqL2xK9",
"invoice_url": "https://vercel.com/dashboard/billing/invoices/inv_2026_07",
"account_email": "jordan.rivera@example.com",
"date": "2026-07-14"
}
}
Validate, then route
Because the result is validated against your schema before it reaches you, your application code can stay small. Check validation_errors first, then branch on status — payment_failed and trial_ending are the two that almost always need to go to a different lane than renewed:
interface ParseResult {
id: string;
status: "completed" | "failed";
validation_errors: unknown[];
data: SubscriptionRenewal;
}
interface SubscriptionRenewal {
subscription_id: string;
product_name: string;
vendor_name: string;
vendor_domain?: string;
plan_name?: string;
billing_interval: "monthly" | "quarterly" | "annual" | "weekly";
amount_cents: number;
currency: string;
renewal_date: string;
next_renewal_date?: string;
payment_method_brand?: string;
payment_method_last4?: string;
status: "renewed" | "trial_ending" | "payment_failed" | "cancelled" | "changed";
manage_url?: string;
invoice_url?: string;
account_email?: string;
date?: string;
}
async function processRenewalEmail(rawMime: string): Promise<void> {
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: "subscription_renewal",
raw_mime: rawMime,
}),
});
const result: ParseResult = await res.json();
// Validation ran server-side against your schema — surface failures, don't guess.
if (result.status !== "completed" || result.validation_errors.length > 0) {
await flagForReview(result);
return;
}
const r = result.data;
// Route by status, not by vendor — every vendor produces the same shape.
switch (r.status) {
case "payment_failed":
// High-signal: a real decline. Page finance-ops, open a support ticket.
await pagePaymentFailure(r);
return;
case "trial_ending":
// Reminder that a paid cycle is about to start. Heads-up, not an alert.
await notifyTrialEnding(r);
return;
case "cancelled":
// Subscription ended. Soft-delete from the active renewals list.
await archiveCancelled(r);
return;
case "renewed":
case "changed":
default:
// Insert into the renewals table; downstream dashboards group by vendor.
await upsertRenewal(r);
}
}
A few practical notes:
- Trust the types, not the strings. Because
statusandbilling_intervalare enum-constrained in the schema, aswitchover them is exhaustive and safe — an unexpected value would have shown up invalidation_errorsrather than reaching this code. - Group by
(subscription_id, renewal_date). A long-lived subscription produces one email per cycle; grouping on both keys keeps a monthly subscription from creating a duplicate row per reminder. - Keep the raw MIME intact. Don’t pre-strip headers before sending — the sender address, the subject line, and the embedded receipt footer are the signal the extractor leans on. Send the message as you received it.
- Compute annualized cost in your consumer. Multiply
amount_centsby12,4, or52formonthly,quarterly, orweeklyrenewals respectively (and by1forannual) to roll spend across billing intervals without losing precision — never derive it from the email itself.
Where this fits in your pipeline
The example above POSTs from wherever you already hold the message — an IMAP poller, an inbound-email webhook, a Lambda reading from S3, a filter forwarding your billing inbox to a queue. That keeps ingestion in your own code and under your own retries, which is the model we generally recommend; the trade-offs are laid out in How to Choose an Email Parsing API and the broader email-to-JSON API overview.
Renewal emails are usually a small share of overall transactional mail, so most teams wire this schema up alongside the other pre-built parsers — invoice emails route into AP via Parse Invoice & Payment-Due Emails, one-time receipts reconcile against Stripe via Parse Stripe Receipts, GitHub notification emails go to a triage queue via Parse GitHub Notification Emails, and renewals end up in a “what is renewing this month” digest. Browse the rest of the schema library for the full list of pre-built parsers.
For teams that would rather receive results asynchronously, signed webhook delivery (HMAC-SHA256, with retries and delivery history) is available in early access — see Designing Webhooks That Don’t Break for how to verify and handle those payloads. Forwarding your billing inbox to a unique MailFrame inbox address, instead of POSTing it yourself, is on the roadmap; until it ships, the direct POST shown here is the supported path.
The full endpoint reference — request and response shapes, error handling, and language samples — lives in the API docs. If you want to try the renewal schema against your own billing emails, request early access and we’ll help you wire it up.