Skip to content
rideshare delivery uber lyft doordash grubhub receipts schema email-to-json tutorial

Parse Rideshare & Delivery Receipt Emails to JSON

Turn Uber, Lyft, DoorDash, and Grubhub receipts into typed JSON: service, trip/delivery ID, fare, tax, tip, currency, merchant, driver/courier, timestamp. Schema-first tutorial.

MailFrame Team

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 (rideshare vs delivery) 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, and total, each as a decimal, plus the ISO 4217 currency. 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_location and dropoff_location for a ride; merchant_name and dropoff_location for a delivery; driver_name for 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_date is when the trip started or the order was placed; receipt_url deep-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, and status are enum-constrained in the schema, a switch over them is exhaustive and safe — an unexpected value would have shown up in validation_errors rather than reaching this code.
  • Dedup on transaction_id. A single ride or order often produces more than one email: an initial receipt, then an adjusted correction when a tip is added after the trip or a partial refund posts. Pairing on transaction_id keeps one row per real trip or order and folds the correction into it. When transaction_id is absent, fall back to (service, total, transaction_date).
  • Keep the numbers separate. Don’t collapse subtotal, tax, tip, and fees into a single total before 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.

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