Skip to content
shipping logistics tracking fedex ups usps dhl schema email-to-json tutorial

Parse Shipping Notification Emails to JSON

Turn FedEx, UPS, USPS, and DHL shipment and delivery notifications into typed JSON — carrier, tracking number, status, locations, and delivery dates. Schema-first tutorial.

MailFrame Team

Carriers send a separate email for almost every meaningful change to a package: the label is created, the package is picked up, it reaches a sort hub, an exception is filed, it goes out for delivery, it’s delivered. Each of those messages contains exactly the facts you need — which carrier, which tracking number, what status, when, and where — wrapped in HTML that differs by carrier and by event type.

MailFrame turns those notifications into a single typed JSON object you can route to a customer notification, a tracking dashboard, or a support queue that needs to know whether a package is still moving.

The Shipping & Delivery Notifications schema page covers the full field reference; this tutorial walks through parsing a carrier notification end to end, routing on status, and wiring the result into your backend.

What you get

MailFrame normalizes FedEx, UPS, USPS, and DHL notifications into one shape, so carrier is the only field that tells them apart:

FieldTypeExampleNotes
carrierenumfedexOne of fedex, ups, usps, dhl
tracking_numberstring9205123456789012345678Carrier’s tracking number, as printed
statusenumdeliveredOne of label_created, picked_up, in_transit, out_for_delivery, delivered, exception, delivery_attempt_failed, returned_to_sender
status_detailstringSigned for by J. RIVERACarrier’s free-text status line when present
origin_locationstringMemphis, TNOrigin city / sort facility when present
destination_locationstringBrooklyn, NY 11201Destination city / postal area when present
estimated_delivery_datestring2026-06-30Carrier’s ETA, normalized to ISO 8601
actual_delivery_datestring2026-06-29Actual delivery date when status is delivered
last_event_atstring2026-06-29T08:14:00-04:00Timestamp of the most recent carrier event, ISO 8601 with offset
last_event_locationstringBrooklyn, NY 11201Location the last event was recorded at
service_levelstringFedEx GroundService level when printed (Ground, 2-Day, Priority, Express, etc.)
weight_lbnumber3.2Package weight in pounds when present
tracking_urlstringhttps://www.fedex.com/fedextrack/?trknbr=9205123456789012345678Carrier’s direct tracking page
reference_numberstringPO-5567Shipper’s reference, PO, or order number when present
datestring2026-06-29Notification date normalized to ISO 8601

carrier, tracking_number, and status are the required fields; everything else is populated when the carrier prints it.

Sample input

A typical FedEx delivery notification email looks like this:

From: tracking@fedex.com
Subject: FedEx delivery notification - 9205123456789012345678
Date: Mon, 29 Jun 2026 08:14:00 -0400
To: jordan@example.com

Your package has been delivered.

Tracking number: 9205123456789012345678
Service:        FedEx Ground
Status:         Delivered
Delivered to:   Brooklyn, NY 11201
Delivered on:   June 29, 2026 at 08:14 AM
Signed by:      J. RIVERA

Track at: https://www.fedex.com/fedextrack/?trknbr=9205123456789012345678

UPS Quantum View Notify messages, USPS Informed Delivery alerts, and DHL On Demand Delivery notifications produce the same JSON keys.

Structured JSON output

POST the raw message to /v1/parse with the schema you want to extract against. The engine currently ships per-carrier schema IDs — fedex-tracking, ups-tracking, usps-tracking, and dhl-tracking — so the example below uses the FedEx one to match the sample above:

curl -X POST https://api.mailframe.ai/v1/parse \
  -H "Authorization: Bearer ${MAILFRAME_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "schema_id": "fedex-tracking",
    "raw_mime": "From: tracking@fedex.com\r\nTo: jordan@example.com\r\nSubject: FedEx delivery notification - 9205123456789012345678\r\nDate: Mon, 29 Jun 2026 08:14:00 -0400\r\n\r\nYour package has been delivered.\r\n\r\nTracking number: 9205123456789012345678\r\nService:        FedEx Ground\r\nStatus:         Delivered\r\nDelivered to:   Brooklyn, NY 11201\r\nDelivered on:   June 29, 2026 at 08:14 AM\r\nSigned by:      J. RIVERA\r\n\r\nTrack at: https://www.fedex.com/fedextrack/?trknbr=9205123456789012345678"
  }'

The call is synchronous: /v1/parse validates the extraction against the schema and returns the typed JSON in the HTTP response, wrapped in the standard MailFrame envelope:

{
  "id": "parse_c3e91a",
  "status": "completed",
  "validation_errors": [],
  "data": {
    "carrier": "fedex",
    "tracking_number": "9205123456789012345678",
    "status": "delivered",
    "status_detail": "Signed for by J. RIVERA",
    "origin_location": "Memphis, TN",
    "destination_location": "Brooklyn, NY 11201",
    "actual_delivery_date": "2026-06-29",
    "last_event_at": "2026-06-29T08:14:00-04:00",
    "last_event_location": "Brooklyn, NY 11201",
    "service_level": "FedEx Ground",
    "weight_lb": 3.2,
    "tracking_url": "https://www.fedex.com/fedextrack/?trknbr=9205123456789012345678",
    "reference_number": "PO-5567",
    "date": "2026-06-29"
  }
}

Note that the envelope’s status is the parse status; the shipment status lives at data.status.

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

const result = await res.json();
// result.data.carrier → "fedex"
// result.data.tracking_number → "9205123456789012345678"
// result.data.status → "delivered"
// result.data.actual_delivery_date → "2026-06-29"

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": "fedex-tracking",
        "raw_mime": raw_email,
    },
)
result = resp.json()
# result["data"]["carrier"] → "fedex"
# result["data"]["tracking_number"] → "9205123456789012345678"
# result["data"]["status"] → "delivered"
# result["data"]["actual_delivery_date"] → "2026-06-29"

Real-world patterns

Routing on status

The most useful split is between normal lifecycle events, completion, and trouble. label_created, picked_up, in_transit, and out_for_delivery fold into a tracking view; delivered closes out the shipment; exception, delivery_attempt_failed, and returned_to_sender deserve a human:

const IN_FLIGHT = new Set([
  "label_created",
  "picked_up",
  "in_transit",
  "out_for_delivery",
]);
const NEEDS_ATTENTION = new Set([
  "exception",
  "delivery_attempt_failed",
  "returned_to_sender",
]);

const shipment = result.data;

if (shipment.status === "delivered") {
  await orders.markFulfilled({
    trackingNumber: shipment.tracking_number,
    deliveredOn: shipment.actual_delivery_date,
  });
} else if (NEEDS_ATTENTION.has(shipment.status)) {
  await support.openTicket({
    trackingNumber: shipment.tracking_number,
    carrier: shipment.carrier,
    reason: shipment.status,
    detail: shipment.status_detail,
    lastSeenAt: shipment.last_event_location,
  });
} else if (IN_FLIGHT.has(shipment.status)) {
  await tracking.recordEvent(shipment);
} else {
  logger.warn("unrecognized shipment status", { status: shipment.status });
}

Those branches cover all eight enum values, but keep the final else as a branch that logs rather than throws — an unfamiliar value should never drop an event on the floor.

Deduplication by carrier and tracking number

A single shipment produces several notifications, and tracking numbers are only unique within a carrier, so the join key is the carrier + tracking_number pair — not the tracking number alone:

// Composite key on (carrier, tracking_number) — each new notification
// advances the same shipment row instead of creating a new one.
await db.query(
  `INSERT INTO shipments (
     carrier, tracking_number, status, status_detail,
     origin_location, destination_location,
     estimated_delivery_date, actual_delivery_date,
     last_event_at, last_event_location, reference_number
   )
   VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
   ON CONFLICT (carrier, tracking_number) DO UPDATE SET
     status = EXCLUDED.status,
     status_detail = EXCLUDED.status_detail,
     estimated_delivery_date = EXCLUDED.estimated_delivery_date,
     actual_delivery_date = EXCLUDED.actual_delivery_date,
     last_event_at = EXCLUDED.last_event_at,
     last_event_location = EXCLUDED.last_event_location,
     updated_at = NOW()
   WHERE shipments.last_event_at IS NULL
      OR EXCLUDED.last_event_at >= shipments.last_event_at`,
  [
    shipment.carrier,
    shipment.tracking_number,
    shipment.status,
    shipment.status_detail,
    shipment.origin_location,
    shipment.destination_location,
    shipment.estimated_delivery_date,
    shipment.actual_delivery_date,
    shipment.last_event_at,
    shipment.last_event_location,
    shipment.reference_number,
  ],
);

The WHERE guard on last_event_at matters: carrier emails do not always arrive in order, and without it a delayed in-transit message can overwrite a delivery you already recorded.

Notifying customers when the ETA moves

estimated_delivery_date is the field customers care about. Comparing it against the value you already stored turns a stream of carrier emails into a single meaningful alert:

const prev = await db.query(
  `SELECT estimated_delivery_date FROM shipments
   WHERE carrier = $1 AND tracking_number = $2`,
  [shipment.carrier, shipment.tracking_number],
);

const previousEta = prev.rows[0]?.estimated_delivery_date;

if (
  shipment.estimated_delivery_date &&
  previousEta &&
  previousEta !== shipment.estimated_delivery_date
) {
  await notify.send({
    type: "delivery_date_changed",
    carrier: shipment.carrier,
    tracking_number: shipment.tracking_number,
    old_date: previousEta,
    new_date: shipment.estimated_delivery_date,
    tracking_url: shipment.tracking_url,
  });
}

Joining shipments back to orders

reference_number carries the shipper’s PO or order number when the carrier prints it, which is the cheapest way to attach a parcel to the order that created it — no lookup table of tracking numbers required:

if (shipment.reference_number) {
  await orders.attachShipment(shipment.reference_number, {
    carrier: shipment.carrier,
    trackingNumber: shipment.tracking_number,
    trackingUrl: shipment.tracking_url,
    serviceLevel: shipment.service_level,
  });
}

Not every carrier prints a reference, so treat this as an enrichment path and keep a fallback that matches on the tracking number you recorded at fulfillment.

Best practices

  • Key on carrier + tracking_number. Tracking numbers are unique per carrier, not globally. A composite key is the correct idempotency key for shipment rows.
  • Order events by last_event_at, not arrival time. Notification emails can arrive out of order or be re-delivered. Only advance a shipment when the new event is at least as recent as the one you stored.
  • Treat optional fields as optional. Only carrier, tracking_number, and status are required. Guard on estimated_delivery_date, reference_number, weight_lb, and the location fields before using them.
  • Branch on the status enum, not the subject line. Carrier subject lines differ per carrier and change over time; the normalized enum is what your routing logic should read.
  • Don’t confuse the two status fields. The envelope’s status describes the parse; data.status describes the package.
  • Keep the raw MIME intact. Don’t pre-strip the HTML before sending — the sender domain and message structure are signal the extractor leans on to tell a FedEx notification from a UPS one.
  • Test every event type per carrier. Label creation, in-transit, out for delivery, delivery, exception, and failed attempt all use different layouts, and each of the four carriers writes them differently. Run a fixture per carrier per status before going to production.

Next steps

The examples above POST from wherever you already hold the message — an IMAP poller on a shared logistics inbox, an S3 trigger on a landing bucket, or a no-code pipeline forwarding carrier notifications to a single mailbox. That keeps ingestion in your own code and under your own retries, which is the model we generally recommend; the broader trade-offs are laid out in How to Choose an Email Parsing API and the email-to-JSON API use case.

For teams that would rather receive results asynchronously, signed webhook delivery (HMAC-SHA256, with exponential-backoff retries) is available in early access — see Designing Webhooks That Don’t Break for how to verify and handle those payloads. Forwarding a logistics 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 field reference lives on the Shipping & Delivery Notifications schema page, and the endpoint reference — request and response shapes, error handling, and language samples — lives in the API docs. If you want to try the shipping notification schemas against your own carrier 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