Skip to content
stripe payments receipts invoices schema email-to-json tutorial

Parse Stripe Receipt Emails to JSON

Turn Stripe payment receipts and invoices into typed JSON — payment intent ID, amount, currency, card brand, customer email, and payment status. Schema-first tutorial.

MailFrame Team

Every Stripe charge — a SaaS subscription, a one-time purchase, an invoice payment — generates a receipt email. The data you actually need (the payment intent ID, the amount, the customer email, the card brand) is locked inside HTML that varies by Stripe’s template version and by whether the charge is a one-off payment, a subscription invoice, or a refund.

MailFrame turns those emails into a single typed JSON object you can write straight to your billing database, reconciliation pipeline, or customer dashboard.

The Stripe Receipts schema page covers the full field reference; this tutorial walks through parsing a real Stripe receipt end to end, handling refunds, and wiring the result into your backend.

What you get

MailFrame’s stripe_receipt schema extracts these fields from Stripe payment receipt and invoice emails:

FieldTypeExampleNotes
reference_idstringpi_3PqL2xK9Payment intent (pi_), charge (ch_), or invoice (in_) token
amount_centsinteger2999Minor units, currency-agnostic
currencystringusdISO 4217, lower-cased
card_brandstringvisaNormalized: visa, mastercard, amex, discover
card_last4string4242Last four digits only — never the full PAN
customer_emailstringjordan@example.comThe recipient on the receipt
merchant_domainstringstripe.comSending domain of the receipt
datestring2026-05-21Normalized to ISO 8601
payment_statusenumpaidOne of paid, refunded, failed

Sample input

A typical Stripe receipt email looks like this:

From: receipts@stripe.com
Subject: Receipt from Acme SaaS Inc. - pi_3PqL2x
Date: Thu, 21 May 2026 10:30:00 -0700
To: jordan@example.com

Thanks for your payment of $29.99 to Acme SaaS Inc.

Receipt ID: pi_3PqL2xK9
Amount paid: $29.99 USD
Card: Visa ending in 4242
Date paid: May 21, 2026

Structured JSON output

Pass the raw email and the schema to MailFrame. 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 stripe_receipt schema against the receipt above:

curl -X POST https://api.mailframe.ai/v1/parse \
  -H "Authorization: Bearer ${MAILFRAME_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "schema_id": "stripe_receipt",
    "raw_mime": "From: receipts@stripe.com\r\nTo: jordan@example.com\r\nSubject: Receipt from Acme SaaS Inc. - pi_3PqL2x\r\nDate: Thu, 21 May 2026 10:30:00 -0700\r\n\r\nThanks for your payment of $29.99 to Acme SaaS Inc.\r\n\r\nReceipt ID: pi_3PqL2xK9\r\nAmount paid: $29.99 USD\r\nCard: Visa ending in 4242\r\nDate paid: May 21, 2026"
  }'

The response wraps the extracted object in the standard MailFrame envelope:

{
  "id": "parse_8f2a1c",
  "status": "completed",
  "validation_errors": [],
  "data": {
    "reference_id": "pi_3PqL2xK9",
    "amount_cents": 2999,
    "currency": "usd",
    "card_brand": "visa",
    "card_last4": "4242",
    "customer_email": "jordan@example.com",
    "merchant_domain": "stripe.com",
    "date": "2026-05-21",
    "payment_status": "paid"
  }
}

TypeScript

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: "stripe_receipt",
    raw_mime: rawEmail,
  }),
});

const result = await res.json();
// result.data.reference_id → "pi_3PqL2xK9"
// result.data.amount_cents → 2999
// result.data.payment_status → "paid"

Python

import os
import requests

resp = requests.post(
    "https://api.mailframe.ai/v1/parse",
    headers={
        "Authorization": f"Bearer {os.environ['MAILFRAME_API_KEY']}",
        "Content-Type": "application/json",
    },
    json={
        "schema_id": "stripe_receipt",
        "raw_mime": raw_email,
    },
)
result = resp.json()
# result["data"]["reference_id"] → "pi_3PqL2xK9"
# result["data"]["amount_cents"] → 2999
# result["data"]["payment_status"] → "paid"

Real-world patterns

Handling refunds

Stripe sends a separate refund receipt when a charge is reversed. The payment_status field distinguishes refunds from original payments, so you can route them to different workflows:

if (result.data.payment_status === "refunded") {
  await billing.recordRefund({
    paymentIntentId: result.data.reference_id,
    amount: result.data.amount_cents,
    currency: result.data.currency,
  });
} else {
  await billing.recordPayment({
    paymentIntentId: result.data.reference_id,
    amount: result.data.amount_cents,
    currency: result.data.currency,
    cardBrand: result.data.card_brand,
    cardLast4: result.data.card_last4,
  });
}

Deduplication by payment intent ID

Stripe may send the same receipt multiple times — once from Stripe’s automated system and again when a customer requests a copy. The reference_id is your natural idempotency key:

// Upsert on reference_id — second parse updates instead of duplicating
await db.query(
  `INSERT INTO payments (reference_id, amount_cents, currency, card_brand, card_last4, customer_email, payment_status, date)
   VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
   ON CONFLICT (reference_id) DO UPDATE SET
     payment_status = EXCLUDED.payment_status,
     updated_at = NOW()`,
  [
    result.data.reference_id,
    result.data.amount_cents,
    result.data.currency,
    result.data.card_brand,
    result.data.card_last4,
    result.data.customer_email,
    result.data.payment_status,
    result.data.date,
  ],
);

Note: reference_id can be a payment intent (pi_), charge (ch_), or invoice (in_) token. If Stripe sends separate emails using different ID types for the same underlying transaction, the upsert key may not catch the duplicate. Consider using a composite key or a separate dedup table if you see duplicates in practice.

Reconciling subscription invoices

For SaaS businesses, Stripe sends an invoice receipt every billing cycle. By parsing each invoice email against the stripe_receipt schema, you can build a payment history per customer without querying the Stripe API:

// Group payments by customer email for a subscription billing view
const payments = await db.query(
  "SELECT * FROM payments WHERE customer_email = $1 ORDER BY date DESC",
  [customerEmail],
);

// Calculate lifetime value from parsed receipts
const ltv = payments.rows.reduce(
  (sum, p) => sum + (p.payment_status === "paid" ? p.amount_cents : 0),
  0,
);

Detecting failed payments

Stripe receipt emails with a declined or failed charge carry payment_status: "failed". The payment_status field lets you trigger retry or notification logic:

if (result.data.payment_status === "failed") {
  await notify.send({
    type: "payment_failed",
    customerEmail: result.data.customer_email,
    amount: result.data.amount_cents,
    currency: result.data.currency,
    message: `Payment of ${(result.data.amount_cents / 100).toFixed(2)} ${result.data.currency.toUpperCase()} failed. Please update your payment method.`,
  });
}

Note: Standalone failed-payment notification emails from Stripe may use a different layout than receipt emails. This pattern applies when the receipt email itself carries a failed status — test against your specific email format before relying on it in production.

Best practices

  • Use reference_id as your idempotency key. Stripe may send the same receipt multiple times. Upsert on the payment intent, charge, or invoice ID to avoid duplicate rows.
  • Store amount_cents as an integer (minor units). Avoid floating-point rounding; convert to dollars only at the display layer.
  • Watch for refund receipts. Stripe sends a separate email for refunds with the same reference_id but payment_status: "refunded". Your upsert logic should update the status rather than inserting a new row.
  • Keep the raw MIME intact. Don’t pre-strip the HTML before sending — the sender domain and the receipt structure are signal the extractor leans on to tell Stripe receipts from other payment processor emails.
  • Test against all three receipt types. Stripe sends different layouts for one-time payments, subscription invoices, and refunds. Test against all three before going to production.

Next steps

The example above POSTs from wherever you already hold the message — an IMAP poller on a shared billing inbox, an S3 trigger on a landing bucket, or a no-code pipeline forwarding Stripe receipts to a single mailbox. 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.

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.

Want to see extraction run live without writing any code? The free Stripe receipt parser runs a pattern-matching preview entirely in your browser — no signup, no API call.

The full endpoint reference — request and response shapes, error handling, and language samples — lives in the API docs. If you want to try the Stripe receipt schema against your own receipts, 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