Every ride and every delivery leaves a receipt in your inbox. Uber and Lyft
send a trip summary the moment the ride ends; DoorDash, Grubhub, Uber Eats, and
Instacart send an order receipt when the courier drops the bag at the door.
Each one arrives from its own sender — receipts@uber.com,
no-reply@lyftmail.com, no-reply@doordash.com, receipts@grubhub.com — with
its own layout, its own way of printing the same numbers, and its own domain
that drifts over time. But the fields you actually care about are the same
across all of them: which service, the trip or order ID, the fare or subtotal,
the tax and tip, the total and currency, where the trip started and ended (or
which merchant filled the order), who drove or delivered, and when.
If you are building expense automation (Expensify, Ramp, Brex, a custom T&E pipeline), a personal-spending tracker, or a mileage- and meal-reimbursement workflow, the first thing you do with one of these receipts is pull out that same handful of fields — only every app names and formats them differently, and half of them are buried in a multipart HTML body. Writing a per-service regex for each is the kind of code that works right up until Uber ships a new receipt template, which is roughly every other quarter.
This post shows how to turn those receipts into typed, validated JSON you can
route to an expense report, a spending dashboard, or a reimbursement job —
using a JSON Schema and a single POST to MailFrame’s /v1/parse endpoint. If
you want the ready-made schema reference, it lives at
Parse Rideshare & Delivery Receipt Emails; this
post is the why-and-how around it.
Everything below uses the shipped, synchronous path: you send raw MIME, you get schema-validated JSON back in the same HTTP response. No inbox to configure, no callback to wait on. (For the reasoning behind the schema-first design, see Why We Built MailFrame Schema-First.)
Why extract rideshare and delivery receipts
A rideshare or delivery receipt is one of the most common expense documents a person receives, and one of the most tedious to file by hand. The fields worth extracting break down into a few groups:
- The service and category.
service(uber,lyft,doordash,grubhub,uber_eats,instacart) tells you which app;service_category(ridesharevsdelivery) is the lane-decider. A ride is a travel expense; a delivery is a meal. Every downstream rule branches on this first. - The money.
fare(the base trip charge, rideshare),subtotal,tax,tip,fees, andtotal, each as a decimal, plus the ISO 4217currency. Expense policies check the tip against the subtotal and the total against the card charge, so keeping them separate matters. - The where and who.
pickup_locationanddropoff_locationfor a ride;merchant_nameanddropoff_locationfor a delivery;driver_namefor the driver or courier. These are what a T&E reviewer looks at to confirm the expense is real and in-policy. - The identity and time.
transaction_id(the trip or order ID) is the dedup key;transaction_dateis when the trip started or the order was placed;receipt_urldeep-links to the full receipt for the audit trail.
Parse those once, up front, and the rest of your pipeline — categorization, policy checks, reimbursement — is plain data handling instead of string scraping.
A JSON Schema for the extraction
MailFrame is schema-first: you declare the exact shape you want before anything is parsed, and every extraction is validated against that contract before it is returned.
Here is a compact schema for rideshare and delivery receipt emails. The enum
constraints on service, service_category, and status are not hints — they
are hard constraints, so a value outside the list fails validation instead of
silently landing in your expense ledger:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "rideshare_delivery_receipt",
"type": "object",
"required": [
"service",
"service_category",
"total",
"currency"
],
"properties": {
"service": {
"type": "string",
"enum": ["uber", "lyft", "doordash", "grubhub", "uber_eats", "instacart", "unknown"]
},
"service_category": {
"type": "string",
"enum": ["rideshare", "delivery", "unknown"]
},
"transaction_id": { "type": "string" },
"status": {
"type": "string",
"enum": ["completed", "canceled", "adjusted", "refunded", "in_progress", "unknown"]
},
"fare": { "type": "number", "minimum": 0 },
"subtotal": { "type": "number", "minimum": 0 },
"tax": { "type": "number", "minimum": 0 },
"tip": { "type": "number", "minimum": 0 },
"fees": { "type": "number", "minimum": 0 },
"total": { "type": "number", "minimum": 0 },
"currency": { "type": "string", "minLength": 3, "maxLength": 3 },
"pickup_location": { "type": "string" },
"dropoff_location":{ "type": "string" },
"merchant_name": { "type": "string" },
"driver_name": { "type": "string" },
"vehicle": { "type": "string" },
"distance": { "type": "string" },
"duration": { "type": "string" },
"item_count": { "type": "integer", "minimum": 0 },
"payment_method": { "type": "string" },
"receipt_url": { "type": "string", "format": "uri" },
"transaction_date":{ "type": "string", "format": "date-time" },
"date": { "type": "string", "format": "date" }
}
}
This is the same schema MailFrame ships pre-built under the id
rideshare_delivery_receipt, so you do not have to register it yourself — but
copying it here is a good starting point if you want to tighten or extend it for
your own routing logic (for example, require merchant_name when you only route
delivery receipts, or restrict service_category to {"rideshare"} if you
only reconcile trip fares for a mileage-reimbursement workflow).
A trimmed sample receipt
Rideshare and delivery receipts are usually multipart messages with a plain-text and an HTML alternative. Here is the plain-text part of a typical Uber ride receipt, trimmed to the fields that matter:
From: Uber Receipts <receipts@uber.com>
Subject: Your Thursday morning trip with Uber
Date: Thu, 03 Jul 2026 08:47:12 -0400
To: jordan.rivera@example.com
Thanks for riding, Jordan
Total $23.84
July 3, 2026 | 8:21 AM
Trip ID: 7f3c9a12-4b8e-4c21-9d6a-1e2f3a4b5c6d
Pickup 8:21 AM 1200 Market St, San Francisco, CA
Dropoff 8:39 AM 500 Terry Francois Blvd, San Francisco, CA
18 min | 3.4 mi
Trip fare $18.20
Booking fee $2.10
Wait time $0.54
Subtotal $20.84
Tip $3.00
Total $23.84
You rode with Marcus
UberX · Toyota Prius
Visa ••••4242
View receipt: https://riders.uber.com/trips/7f3c9a12
Send the raw email to /v1/parse
MailFrame’s shipped path is direct and synchronous: you POST the raw RFC 822
MIME text of the email — exactly the bytes you received — along with a schema,
and you get typed JSON back in the same HTTP response.
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
rideshare_delivery_receipt schema against the Uber ride 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": "rideshare_delivery_receipt",
"raw_mime": "From: Uber Receipts <receipts@uber.com>\r\nTo: jordan.rivera@example.com\r\nSubject: Your Thursday morning trip with Uber\r\nDate: Thu, 03 Jul 2026 08:47:12 -0400\r\n\r\nThanks for riding, Jordan\r\n\r\nTotal $23.84\r\nJuly 3, 2026 | 8:21 AM\r\n\r\nTrip ID: 7f3c9a12-4b8e-4c21-9d6a-1e2f3a4b5c6d\r\n\r\nPickup 8:21 AM 1200 Market St, San Francisco, CA\r\nDropoff 8:39 AM 500 Terry Francois Blvd, San Francisco, CA\r\n18 min | 3.4 mi\r\n\r\nTrip fare $18.20\r\nBooking fee $2.10\r\nWait time $0.54\r\nSubtotal $20.84\r\nTip $3.00\r\nTotal $23.84\r\n\r\nYou rode with Marcus\r\nUberX · Toyota Prius\r\n\r\nVisa ••••4242\r\n\r\nView receipt: https://riders.uber.com/trips/7f3c9a12"
}'
The response wraps the extracted, schema-valid data in a data object
alongside the parse status and any validation errors:
{
"id": "parse_9c41e7",
"status": "completed",
"validation_errors": [],
"data": {
"service": "uber",
"service_category": "rideshare",
"transaction_id": "7f3c9a12-4b8e-4c21-9d6a-1e2f3a4b5c6d",
"status": "completed",
"fare": 18.2,
"subtotal": 20.84,
"tax": 0,
"tip": 3,
"fees": 2.64,
"total": 23.84,
"currency": "USD",
"pickup_location": "1200 Market St, San Francisco, CA",
"dropoff_location": "500 Terry Francois Blvd, San Francisco, CA",
"driver_name": "Marcus",
"vehicle": "UberX · Toyota Prius",
"distance": "3.4 mi",
"duration": "18 min",
"payment_method": "Visa ••••4242",
"receipt_url": "https://riders.uber.com/trips/7f3c9a12",
"transaction_date": "2026-07-03T08:21:00-04:00",
"date": "2026-07-03"
}
}
Validate, then route
Because the result is validated against your schema before it reaches you, your
application code can stay small. Check validation_errors first, then branch on
service_category (the broad lane) and status (routine vs correction):
interface ParseResult {
id: string;
status: "completed" | "failed";
validation_errors: unknown[];
data: RideshareDeliveryReceipt;
}
interface RideshareDeliveryReceipt {
service:
| "uber"
| "lyft"
| "doordash"
| "grubhub"
| "uber_eats"
| "instacart"
| "unknown";
service_category: "rideshare" | "delivery" | "unknown";
transaction_id?: string;
status:
| "completed"
| "canceled"
| "adjusted"
| "refunded"
| "in_progress"
| "unknown";
fare?: number;
subtotal?: number;
tax?: number;
tip?: number;
fees?: number;
total: number;
currency: string;
pickup_location?: string;
dropoff_location?: string;
merchant_name?: string;
driver_name?: string;
vehicle?: string;
distance?: string;
duration?: string;
item_count?: number;
payment_method?: string;
receipt_url?: string;
transaction_date?: string;
date?: string;
}
async function processReceiptEmail(rawMime: string): Promise<void> {
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: "rideshare_delivery_receipt",
raw_mime: rawMime,
}),
});
const result: ParseResult = await res.json();
// Validation ran server-side against your schema — surface failures, don't guess.
if (result.status !== "completed" || result.validation_errors.length > 0) {
await flagForReview(result);
return;
}
const r = result.data;
// Corrections reconcile against the original charge instead of creating a new one.
if (r.status === "canceled" || r.status === "adjusted" || r.status === "refunded") {
await reconcileAdjustment(r);
return;
}
// Broad lane split — a ride is travel, a delivery is a meal.
if (r.service_category === "rideshare") {
await fileTravelExpense(r); // uses pickup/dropoff, distance, duration
} else if (r.service_category === "delivery") {
await fileMealExpense(r); // uses merchant_name, item_count, subtotal
} else {
await flagForReview(result);
}
}
A few practical notes:
- Trust the types, not the strings. Because
service,service_category, andstatusare enum-constrained in the schema, aswitchover them is exhaustive and safe — an unexpected value would have shown up invalidation_errorsrather than reaching this code. - Dedup on
transaction_id. A single ride or order often produces more than one email: an initial receipt, then anadjustedcorrection when a tip is added after the trip or a partial refund posts. Pairing ontransaction_idkeeps one row per real trip or order and folds the correction into it. Whentransaction_idis absent, fall back to(service, total, transaction_date). - Keep the numbers separate. Don’t collapse
subtotal,tax,tip, andfeesinto a singletotalbefore you store them — expense policies check the tip percentage and the tax against the subtotal, and you can’t recover the breakdown once it’s gone. - Keep the raw MIME intact. Don’t pre-strip the HTML part before sending — the sender domain and the receipt’s own structure are signal the extractor leans on to tell Uber from Lyft from DoorDash, and a ride from a delivery. Send the message as you received it.
Where this fits in your pipeline
The example above POSTs from wherever you already hold the message — an IMAP poller on a personal inbox, an inbound-email webhook on a shared expenses alias, a Lambda reading from S3, or a no-code pipeline forwarding 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 and the broader email-to-JSON API overview.
Rideshare and delivery receipts are usually one lane of a broader expense-and-logistics mail pipeline, so most teams wire this schema up alongside the other pre-built parsers — Parse Shipping & Delivery Notifications handles the FedEx/UPS/USPS parcel side, Parse Travel Booking Confirmations covers flights, hotels, and rental cars, and Parse Bank & Card Transactions catches the card charge these receipts reconcile against. Browse the rest of the schema library for the full list of pre-built parsers.
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. Forwarding an expenses 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 endpoint reference — request and response shapes, error handling, and language samples — lives in the API docs. If you want to try the rideshare-and-delivery schema against your own receipts, request early access and we’ll help you wire it up.