Every PayPal payment — whether you are sending money to a freelancer, receiving a customer payment on a marketplace, or processing a refund — lands in your inbox as a receipt email. The transaction ID, the fee breakdown, the net amount, and the payer’s email are all there, but they are scattered across inconsistent HTML layouts that change depending on whether the payment was personal, commercial, or a refund.
MailFrame turns those emails into a single typed JSON object you can write straight to your payment reconciliation ledger, accounting system, or fraud-monitoring queue.
The PayPal Receipts schema page covers the full field reference; this tutorial walks through parsing a real PayPal receipt email end to end, handling refunds, and wiring the result into your backend.
What you’ll build
By the end of this tutorial you’ll have a working integration that:
- Accepts a raw PayPal receipt email (MIME or plain text)
- Sends it to MailFrame’s
/v1/parseendpoint with thepaypal_receiptschema - Receives typed, schema-validated JSON with transaction ID, amount, fees, net proceeds, payer and recipient emails, and payment status
- Handles the response — both successful parses and validation errors
Prerequisites
- A MailFrame API key (request early access from the home page)
- The raw MIME or plain-text content of a PayPal receipt email
curl(or your preferred HTTP client)
Step 1 — Understand the PayPal receipt schema
The paypal_receipt schema extracts the fields that matter for payment
reconciliation:
| Field | Type | Example | Purpose |
|---|---|---|---|
transaction_id | string | 8XJ12345AB678901C | PayPal transaction ID for reconciliation |
amount_cents | integer | 5000 | Gross payment amount in minor units (cents) |
currency | string | usd | ISO 4217, lower-cased |
fee_cents | integer | 175 | PayPal processing fee in minor units |
net_cents | integer | 4825 | Amount after fees (amount_cents - fee_cents) |
payer_email | string | sam@example.com | Email of the sender |
recipient_email | string | shop@acmeco.com | Email of the recipient |
payment_status | enum | completed | One of completed, pending, refunded |
date | string | 2026-05-21 | Transaction date normalized to ISO 8601 |
The schema enforces types and required fields at parse time — if
transaction_id or amount_cents is missing from the extraction, MailFrame
returns a validation_errors array alongside the partial data so you can
decide how to handle it.
Step 2 — Get a real PayPal receipt email
To test the parser, you need the raw content of a PayPal receipt email. The easiest way to get one:
- Send a small payment to another PayPal account (or ask someone to send one to you).
- Open the receipt email in your email client and choose “Show original” or “Download .eml” — this gives you the raw RFC 822 MIME source.
- Save the content to a file, for example
paypal_receipt.eml.
If you don’t have a real email handy, here’s a representative sample you can use for testing:
From: service@paypal.com
Subject: Receipt for your payment to Acme Co.
Date: Wed, 21 May 2026 11:45:00 -0700
To: sam@example.com
Hello Sam,
You sent a payment of $50.00 USD to Acme Co. (shop@acmeco.com).
Transaction ID: 8XJ12345AB678901C
Date: May 21, 2026, 11:45 AM PDT
Payment details:
Amount sent: $50.00
PayPal fee: $1.75
Net amount: $48.25
Payment method: PayPal balance
Status: Completed
Step 3 — 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 $MAILFRAME_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"schema": "paypal_receipt",
"input": { "type": "email", "raw": "RnJvbTogc2VydmljZUBwYXlwYWwuY29tClN1YmplY3Q6IFJlY2VpcHQgZm9yIHlvdXIgcGF5bWVudCB0byBBY21lIENvLgpEYXRlOiBXZWQsIDIxIE1heSAyMDI2IDExOjQ1OjAwIC0wNzAwClRvOiBzYW1AZXhhbXBsZS5jb20KCkhlbGxvIFNhbSwKCllvdSBzZW50IGEgcGF5bWVudCBvZiAkNTAuMDAgVVNEIHRvIEFjbWUgQ28uIChzaG9wQGFjbWVjby5jb20pLgoKVHJhbnNhY3Rpb24gSUQ6IDhYSjEyMzQ1QUI2Nzg5MDFDCkRhdGU6IE1heSAyMSwgMjAyNiwgMTE6NDUgQU0gUERUCgpQYXltZW50IGRldGFpbHM6CiAgQW1vdW50IHNlbnQ6ICAgICQ1MC4wMAogIFBheVBhbCBmZWU6ICAgICAgJDEuNzUKICBOZXQgYW1vdW50OiAgICAkNDguMjUKClBheW1lbnQgbWV0aG9kOiBQYXlQYWwgYmFsYW5jZQpTdGF0dXM6IENvbXBsZXRlZA==" }
}'
If you saved the email to a file, you can build the payload with jq:
jq -n --rawfile raw paypal_receipt.eml \
'{schema: "paypal_receipt", input: {type: "email", raw: $raw}}' | \
curl https://api.mailframe.ai/v1/parse \
-H "Authorization: Bearer $MAILFRAME_API_KEY" \
-H "Content-Type: application/json" \
-d @-
Step 4 — Read the response
A successful parse returns HTTP 200 with the typed data:
{
"id": "parse_8f2a1c",
"status": "completed",
"validation_errors": [],
"data": {
"transaction_id": "8XJ12345AB678901C",
"amount_cents": 5000,
"currency": "usd",
"fee_cents": 175,
"net_cents": 4825,
"payer_email": "sam@example.com",
"recipient_email": "shop@acmeco.com",
"payment_status": "completed",
"date": "2026-05-21"
}
}
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 doesn’t match, the errors are listed here — but thedataobject 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.
Step 5 — Handle the response in your application
Here’s how to integrate the parse result into a typical payment-reconciliation pipeline:
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: "paypal_receipt",
input: { type: "email", raw: 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) {
// Write straight to your database — already typed and validated
await db.payments.insert({
transactionId: data.transaction_id,
grossCents: data.amount_cents,
feeCents: data.fee_cents,
netCents: data.net_cents,
currency: data.currency,
payerEmail: data.payer_email,
recipientEmail: data.recipient_email,
paymentStatus: data.payment_status,
transactionDate: data.date,
});
console.log(`Payment ${data.transaction_id} recorded`);
} else {
console.warn("Parse issues:", { status, validation_errors, data });
}
Python
import os
import requests
res = requests.post(
"https://api.mailframe.ai/v1/parse",
headers={"Authorization": f"Bearer {os.environ['MAILFRAME_API_KEY']}"},
json={
"schema": "paypal_receipt",
"input": {"type": "email", "raw": raw_email_text},
},
)
res.raise_for_status()
body = res.json()
if body["status"] == "completed" and not body.get("validation_errors"):
payment = body["data"]
# Write straight to your database — already typed and validated
db.payments.insert({
"transaction_id": payment["transaction_id"],
"gross_cents": payment["amount_cents"],
"fee_cents": payment["fee_cents"],
"net_cents": payment["net_cents"],
"currency": payment["currency"],
"payer_email": payment["payer_email"],
"recipient_email": payment["recipient_email"],
"payment_status": payment["payment_status"],
"transaction_date": payment["date"],
})
print(f"Payment {payment['transaction_id']} recorded")
else:
print("Parse issues:", body["status"], body.get("validation_errors"))
Real-world integration tips
Track the fee breakdown for reconciliation
PayPal’s fee structure varies by transaction type: personal payments have
different fees than commercial payments, and international payments carry a
currency-conversion fee on top. The fee_cents field captures the total
PayPal fee, and net_cents gives you the amount that actually lands in the
recipient’s PayPal balance. For reconciliation, store all three — amount_cents,
fee_cents, and net_cents — so you can verify that amount_cents - fee_cents
equals net_cents and flag any discrepancy.
Handle refunds as separate transactions
When a payment is refunded, PayPal sends a new receipt email with the same
original transaction_id but a payment_status of refunded. The
amount_cents in a refund email is the refunded amount (which may be less
than the original if the refund is partial). Use transaction_id as your
lookup key and payment_status to distinguish the original payment from the
refund:
// Upsert on transaction_id — refund updates the status
await db.query(
`INSERT INTO payments (transaction_id, gross_cents, fee_cents, net_cents, currency, payment_status, transaction_date)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (transaction_id) DO UPDATE SET
payment_status = EXCLUDED.payment_status,
net_cents = EXCLUDED.net_cents,
updated_at = NOW()`,
[
data.transaction_id,
data.amount_cents,
data.fee_cents,
data.net_cents,
data.currency,
data.payment_status,
data.date,
],
);
Currency is always in minor units
All monetary values (amount_cents, fee_cents, net_cents) are integers
representing the amount in the smallest currency unit (cents for USD, pence for
GBP, etc.). Convert to display format in your UI layer, not before storing —
this avoids floating-point rounding issues in calculations.
Watch for pending payments
PayPal payments can remain in pending status for several days — for example,
when the payment is an eCheck that hasn’t cleared yet, or when PayPal is
reviewing the transaction. The payment_status field captures this so you can
queue the payment for later reconciliation rather than treating it as
completed. Poll the PayPal API or wait for the follow-up receipt email that
updates the status to completed or refunded.
Deduplicate by transaction ID
PayPal sometimes sends the same receipt email twice — once immediately and
once as a daily summary. The transaction_id field is your natural
idempotency key. Upsert on it to avoid double-counting payments in your
ledger.
What’s next
- Browse the full schema library for other email types — Stripe receipts, Shopify orders, Amazon orders, and more.
- Read about signed webhook delivery for asynchronous processing with retries, dead-letter queues, and replay.
- Try the free in-browser Stripe receipt parser to see the extraction concept in action without an API key.