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:
| Field | Type | Example | Notes |
|---|---|---|---|
carrier | enum | fedex | One of fedex, ups, usps, dhl |
tracking_number | string | 9205123456789012345678 | Carrier’s tracking number, as printed |
status | enum | delivered | One of label_created, picked_up, in_transit, out_for_delivery, delivered, exception, delivery_attempt_failed, returned_to_sender |
status_detail | string | Signed for by J. RIVERA | Carrier’s free-text status line when present |
origin_location | string | Memphis, TN | Origin city / sort facility when present |
destination_location | string | Brooklyn, NY 11201 | Destination city / postal area when present |
estimated_delivery_date | string | 2026-06-30 | Carrier’s ETA, normalized to ISO 8601 |
actual_delivery_date | string | 2026-06-29 | Actual delivery date when status is delivered |
last_event_at | string | 2026-06-29T08:14:00-04:00 | Timestamp of the most recent carrier event, ISO 8601 with offset |
last_event_location | string | Brooklyn, NY 11201 | Location the last event was recorded at |
service_level | string | FedEx Ground | Service level when printed (Ground, 2-Day, Priority, Express, etc.) |
weight_lb | number | 3.2 | Package weight in pounds when present |
tracking_url | string | https://www.fedex.com/fedextrack/?trknbr=9205123456789012345678 | Carrier’s direct tracking page |
reference_number | string | PO-5567 | Shipper’s reference, PO, or order number when present |
date | string | 2026-06-29 | Notification 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, andstatusare required. Guard onestimated_delivery_date,reference_number,weight_lb, and the location fields before using them. - Branch on the
statusenum, 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
statusfields. The envelope’sstatusdescribes the parse;data.statusdescribes 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.