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:
| Field | Type | Example | Notes |
|---|---|---|---|
reference_id | string | pi_3PqL2xK9 | Payment intent (pi_), charge (ch_), or invoice (in_) token |
amount_cents | integer | 2999 | Minor units, currency-agnostic |
currency | string | usd | ISO 4217, lower-cased |
card_brand | string | visa | Normalized: visa, mastercard, amex, discover |
card_last4 | string | 4242 | Last four digits only — never the full PAN |
customer_email | string | jordan@example.com | The recipient on the receipt |
merchant_domain | string | stripe.com | Sending domain of the receipt |
date | string | 2026-05-21 | Normalized to ISO 8601 |
payment_status | enum | paid | One 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_idcan 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
failedstatus — test against your specific email format before relying on it in production.
Best practices
- Use
reference_idas 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_centsas 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_idbutpayment_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.