Skip to content
paypal payments receipts refunds schema email-to-json tutorial

Parse PayPal Receipt Emails to JSON

Turn PayPal payment receipts and refund confirmations into typed JSON — transaction ID, amount, currency, fees, net proceeds, payer, recipient, and payment status. Schema-first tutorial.

MailFrame Team

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:

  1. Accepts a raw PayPal receipt email (MIME or plain text)
  2. Sends it to MailFrame’s /v1/parse endpoint with the paypal_receipt schema
  3. Receives typed, schema-validated JSON with transaction ID, amount, fees, net proceeds, payer and recipient emails, and payment status
  4. 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:

FieldTypeExamplePurpose
transaction_idstring8XJ12345AB678901CPayPal transaction ID for reconciliation
amount_centsinteger5000Gross payment amount in minor units (cents)
currencystringusdISO 4217, lower-cased
fee_centsinteger175PayPal processing fee in minor units
net_centsinteger4825Amount after fees (amount_cents - fee_cents)
payer_emailstringsam@example.comEmail of the sender
recipient_emailstringshop@acmeco.comEmail of the recipient
payment_statusenumcompletedOne of completed, pending, refunded
datestring2026-05-21Transaction 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:

  1. Send a small payment to another PayPal account (or ask someone to send one to you).
  2. Open the receipt email in your email client and choose “Show original” or “Download .eml” — this gives you the raw RFC 822 MIME source.
  3. 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 the data object 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

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