Skip to content
subscriptions saas schema email-to-json tutorial

Parse Subscription Renewal Emails to JSON

Turn Vercel, Netlify, OpenAI, Notion, Linear, Figma, and other SaaS renewal emails into typed JSON: subscription ID, product, plan, next renewal date, amount, payment method, and status. Schema-first.

MailFrame Team

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 identitysubscription_id from the vendor (Vercel writes sub_1PqL2xK9, Stripe writes sub_..., 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 planproduct_name is the thing you are paying for (Vercel Pro, Notion Plus, Linear Standard); plan_name is 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 termsbilling_interval (monthly | quarterly | annual | weekly) and amount_cents plus currency. Storing the amount in minor units avoids floating-point drift across dozens of currencies.
  • The renewal daterenewal_date (and the alias next_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 methodpayment_method_brand normalized (visa, mastercard, amex) and payment_method_last4 (4242). Useful for flagging renewals charged to a card that is about to expire.
  • The statusrenewed | trial_ending | payment_failed | cancelled | changed. This is the single most useful field for triage: a payment_failed deserves a pager; a trial_ending deserves a heads-up; a cancelled deserves an archival update.

The fields worth extracting are roughly:

FieldWhy you want it
subscription_idJoin key across the lifetime of a subscription
vendor_name / vendor_domainGroup renewals by billing party for spend rollups
product_name / plan_nameSlice spend by product and by tier
billing_intervalCompute annualized cost (monthly × 12, quarterly × 4, etc.)
amount_cents + currencySum actual charges without floating-point error
renewal_date”What’s renewing this month?”
payment_method_brand + last4Catch renewals on cards about to expire
statusPage 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 statuspayment_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 status and billing_interval are enum-constrained in the schema, a switch over them is exhaustive and safe — an unexpected value would have shown up in validation_errors rather 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_cents by 12, 4, or 52 for monthly, quarterly, or weekly renewals respectively (and by 1 for annual) 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.

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