Every e-commerce order generates a trail of emails: order confirmations, shipping notifications, delivery updates, return labels, refund receipts. For most applications, that data is locked inside unstructured HTML. Parsing it yourself means writing and maintaining merchant-specific templates for Amazon, Walmart, Target, and thousands of other retailers — each with its own email format that can change without notice.
Nylas ExtractAI handles this for you. It monitors a user’s inbox, detects emails containing order, shipment, or return information, and extracts structured data automatically. You get clean JSON with order numbers, product details, tracking links, carrier names, and refund statuses. No email parsing, no regex, no template maintenance. ExtractAI supports over 30,000 merchants out of the box, and it works across Google and Microsoft accounts.
What you’ll build
Section titled “What you’ll build”This tutorial walks through building an order-tracking pipeline that:
- Receives real-time webhook notifications when ExtractAI detects an order, shipment, or return email
- Processes each notification type and stores the structured data
- Queries the Order Consolidation API to pull historical order data on demand
- Consolidates orders with their matching shipments and returns into a unified view
The examples use Node.js with Express and Python with Flask. The consolidation logic is straightforward so you can adapt it to feed a customer dashboard, analytics system, or internal tool.
Before you begin
Section titled “Before you begin”Make sure you have the following before starting this tutorial:
- A Nylas account with an active application
- A valid API key from your Nylas Dashboard
- At least one connected grant (an authenticated user account) for the provider you want to work with
- Node.js 18+ or Python 3.8+ installed (depending on which code samples you follow)
New to Nylas? Start with the quickstart guide to set up your app and connect a test account before continuing here.
You also need:
- ExtractAI enabled in your Nylas Dashboard (see Activate ExtractAI below)
- A Google or Microsoft grant — ExtractAI does not support IMAP connectors
- OAuth scopes:
gmail.readonlyfor Google, orMail.Readfor Microsoft (addMail.Read.Sharedif you need access to shared mailboxes) - A publicly accessible HTTPS endpoint for webhook delivery. During development, use VS Code port forwarding or Hookdeck to expose your local server.
- Express (Node.js) or Flask (Python) installed in your project.
Nylas blocks requests to Ngrok URLs because of throughput limiting concerns. Use VS Code port forwarding or Hookdeck instead.
Activate ExtractAI
Section titled “Activate ExtractAI”Before you can receive order data, you need to turn on ExtractAI in the Nylas Dashboard:
- Log in to the Nylas Dashboard.
- Select ExtractAI from the left navigation.
- Click Enable ExtractAI.
Nylas automatically creates a Pub/Sub channel subscribed to the three ExtractAI triggers. Do not delete this channel from the Notifications page — if you do, the ExtractAI APIs stop working.
Do not delete the auto-created Pub/Sub channel. Nylas creates it when you enable ExtractAI. Removing it breaks the entire feature, and you will need to contact support to restore it.
For full setup details, see the ExtractAI documentation.
Set up webhooks for order notifications
Section titled “Set up webhooks for order notifications”Create a webhook subscription that listens for all three ExtractAI triggers. This tells Nylas to send a POST request to your endpoint whenever it detects an order confirmation, shipping update, or return notification in any connected grant’s inbox.
curl --request POST \ --url 'https://api.us.nylas.com/v3/webhooks/' \ --header 'Content-Type: application/json' \ --header 'Authorization: Bearer <NYLAS_API_KEY>' \ --data '{ "trigger_types": [ "message.intelligence.order", "message.intelligence.tracking", "message.intelligence.return" ], "description": "ExtractAI order tracking", "webhook_url": "<YOUR_WEBHOOK_URL>", "notification_email_addresses": ["[email protected]"] }'Replace <NYLAS_API_KEY> with your API key and <YOUR_WEBHOOK_URL> with the public HTTPS URL where your server is listening.
Nylas returns the webhook object with a webhook_secret. Save this value — you need it to verify incoming notifications.
Handle the challenge verification
Section titled “Handle the challenge verification”When you create the webhook, Nylas sends a GET request to your endpoint with a challenge query parameter. Your server must return the exact value in a 200 response within 10 seconds. No JSON wrapping, no extra whitespace — just the raw string.
app.get("/webhook", (req, res) => { const challenge = req.query.challenge; if (challenge) { return res.status(200).send(challenge); } res.status(400).send("Missing challenge parameter");});@app.route("/webhook", methods=["GET"])def handle_challenge(): challenge = request.args.get("challenge") if challenge: return challenge, 200 return "Missing challenge parameter", 400Handle order notifications
Section titled “Handle order notifications”Once the webhook is active, Nylas sends a POST request every time ExtractAI detects order, shipment, or return data in an email. Each notification type has a different payload structure, so your handler needs to route based on the type field.
Here is what a message.intelligence.order notification looks like (trimmed for clarity):
{ "type": "message.intelligence.order", "data": { "object": { "confidence_score": 1, "grant_id": "<NYLAS_GRANT_ID>", "merchant": { "domain": "example.com", "name": "Acme Store" }, "orders": [{ "currency": "USD", "line_items": [{ "name": "Wireless Headphones", "quantity": 1, "unit_price": 4999 }], "order_date": "1632726000", "order_number": "ORD-98234", "total_amount": 5429, "total_tax_amount": 430 }], "status": "SUCCESS" } }}Notice that unit_price and total_amount are in cents, not dollars. A value of 4999 means $49.99. This is consistent across all ExtractAI responses. For the full payload schema, see the notification schemas reference.
Node.js webhook handler
Section titled “Node.js webhook handler”const express = require("express");const crypto = require("crypto");
const app = express();const WEBHOOK_SECRET = process.env.NYLAS_WEBHOOK_SECRET;
app.use( "/webhook", express.raw({ type: "application/json" }));
app.get("/webhook", (req, res) => { const challenge = req.query.challenge; if (challenge) return res.status(200).send(challenge); res.status(400).send("Missing challenge parameter");});
app.post("/webhook", async (req, res) => { // 1. Verify the HMAC signature const signature = req.headers["x-nylas-signature"]; const digest = crypto .createHmac("sha256", WEBHOOK_SECRET) .update(req.body) .digest("hex");
if (signature !== digest) { console.error("Invalid webhook signature"); return res.status(401).send("Invalid signature"); }
// 2. Respond immediately to prevent retries res.status(200).send("OK");
// 3. Parse and route by notification type const notification = JSON.parse(req.body); const { type, data } = notification; const obj = data.object;
switch (type) { case "message.intelligence.order": handleOrderNotification(obj); break; case "message.intelligence.tracking": handleTrackingNotification(obj); break; case "message.intelligence.return": handleReturnNotification(obj); break; default: console.log(`Unknown notification type: ${type}`); }});
function handleOrderNotification(obj) { for (const order of obj.orders || []) { const record = { grantId: obj.grant_id, orderNumber: order.order_number, merchant: obj.merchant?.name || obj.merchant?.domain, totalCents: order.total_amount, taxCents: order.total_tax_amount, currency: order.currency, orderDate: order.order_date, items: (order.line_items || []).map((item) => ({ name: item.name, quantity: item.quantity, unitPriceCents: item.unit_price, })), };
console.log("Order detected:", JSON.stringify(record, null, 2)); // Save to your database or forward to your application }}
function handleTrackingNotification(obj) { for (const shipment of obj.shippings || []) { const record = { grantId: obj.grant_id, orderNumber: shipment.order_number, carrier: shipment.carrier, trackingNumber: shipment.tracking_number, trackingLink: shipment.tracking_link, merchant: obj.merchant?.name || obj.merchant?.domain, };
console.log("Shipment detected:", JSON.stringify(record, null, 2)); }}
function handleReturnNotification(obj) { for (const ret of obj.returns || []) { const record = { grantId: obj.grant_id, orderNumber: ret.order_number, refundTotalCents: ret.refund_total, returnDate: ret.returns_date, merchant: obj.merchant?.name || obj.merchant?.domain, };
console.log("Return detected:", JSON.stringify(record, null, 2)); }}
app.listen(3000, () => console.log("ExtractAI webhook server running on port 3000"));Python webhook handler
Section titled “Python webhook handler”import hashlibimport hmacimport jsonimport os
from flask import Flask, request
app = Flask(__name__)WEBHOOK_SECRET = os.environ["NYLAS_WEBHOOK_SECRET"]
@app.route("/webhook", methods=["GET"])def handle_challenge(): challenge = request.args.get("challenge") if challenge: return challenge, 200 return "Missing challenge parameter", 400
@app.route("/webhook", methods=["POST"])def handle_notification(): # 1. Verify the HMAC signature signature = request.headers.get("x-nylas-signature") raw_body = request.get_data() digest = hmac.new( WEBHOOK_SECRET.encode(), raw_body, hashlib.sha256 ).hexdigest()
if not hmac.compare_digest(signature or "", digest): return "Invalid signature", 401
# 2. Parse and route by notification type notification = json.loads(raw_body) notification_type = notification.get("type") obj = notification.get("data", {}).get("object", {})
if notification_type == "message.intelligence.order": handle_order_notification(obj) elif notification_type == "message.intelligence.tracking": handle_tracking_notification(obj) elif notification_type == "message.intelligence.return": handle_return_notification(obj)
return "OK", 200
def _merchant_name(obj: dict) -> str: merchant = obj.get("merchant") or {} return merchant.get("name") or merchant.get("domain", "Unknown")
def handle_order_notification(obj: dict): for order in obj.get("orders", []): print(f"Order detected: {order.get('order_number')} " f"from {_merchant_name(obj)} - " f"{order.get('total_amount')} {order.get('currency')}") # Save to your database
def handle_tracking_notification(obj: dict): for shipment in obj.get("shippings", []): print(f"Shipment detected: {shipment.get('carrier')} " f"#{shipment.get('tracking_number')}") # Save to your database
def handle_return_notification(obj: dict): for ret in obj.get("returns", []): print(f"Return detected: order {ret.get('order_number')} " f"refund {ret.get('refund_total')} cents") # Save to your database
if __name__ == "__main__": app.run(port=3000)Respond with 200 before doing heavy processing. If your handler takes too long, Nylas considers the delivery failed and retries. Acknowledge the webhook immediately, then process the data asynchronously.
Query the Order Consolidation API
Section titled “Query the Order Consolidation API”Webhooks give you real-time notifications, but sometimes you need to pull data on demand. The Order Consolidation API lets you query for all orders, shipments, or returns associated with a grant. This is useful for building initial views, backfilling data, or running periodic syncs.
Fetch orders
Section titled “Fetch orders”curl --request GET \ --url 'https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/consolidated-order' \ --header 'Accept: application/json' \ --header 'Authorization: Bearer <NYLAS_API_KEY>'const NYLAS_API_KEY = process.env.NYLAS_API_KEY;
async function fetchOrders(grantId) { const response = await fetch( `https://api.us.nylas.com/v3/grants/${grantId}/consolidated-order`, { headers: { Authorization: `Bearer ${NYLAS_API_KEY}` }, } ); return (await response.json()).data;}import os, requests
NYLAS_API_KEY = os.environ["NYLAS_API_KEY"]
def fetch_orders(grant_id: str) -> list: response = requests.get( f"https://api.us.nylas.com/v3/grants/{grant_id}/consolidated-order", headers={"Authorization": f"Bearer {NYLAS_API_KEY}"}, ) return response.json().get("data", [])Fetch shipments and returns
Section titled “Fetch shipments and returns”The shipment and return endpoints follow the same pattern — swap consolidated-order with consolidated-shipment or consolidated-return:
# Fetch shipmentscurl --request GET \ --url 'https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/consolidated-shipment' \ --header 'Accept: application/json' \ --header 'Authorization: Bearer <NYLAS_API_KEY>'
# Fetch returnscurl --request GET \ --url 'https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/consolidated-return' \ --header 'Accept: application/json' \ --header 'Authorization: Bearer <NYLAS_API_KEY>'The Node.js and Python functions are identical to fetchOrders above — just change the URL path. All three endpoints return paginated results via next_cursor. If the response contains a next_cursor value, pass it as a page_token query parameter to fetch the next page.
Build an order tracking dashboard
Section titled “Build an order tracking dashboard”The real value of ExtractAI shows up when you combine orders, shipments, and returns into a single view per order. The Order Consolidation API already links shipments and returns to their parent orders through order_id, so the join is straightforward.
Consolidate order data
Section titled “Consolidate order data”async function buildOrderDashboard(grantId) { const [orders, shipments, returns] = await Promise.all([ fetchOrders(grantId), fetchShipments(grantId), fetchReturns(grantId), ]);
// Index shipments and returns by order_id const shipmentsByOrder = {}; for (const s of shipments) { const id = s.order?.order_id; if (!id) continue; if (!shipmentsByOrder[id]) shipmentsByOrder[id] = []; shipmentsByOrder[id].push({ carrier: s.carrier_name, trackingNumber: s.tracking_number, deliveryStatus: s.carrier_enrichment?.delivery_status?.description || null, }); }
const returnsByOrder = {}; for (const r of returns) { const id = r.order?.order_id; if (!id) continue; if (!returnsByOrder[id]) returnsByOrder[id] = []; returnsByOrder[id].push({ returnDate: r.returns_date, refundTotalCents: r.refund_total, }); }
return orders.map((order) => ({ orderId: order.order_id, merchant: order.merchant_name, totalCents: order.order_total, currency: order.currency, products: order.products || [], shipments: shipmentsByOrder[order.order_id] || [], returns: returnsByOrder[order.order_id] || [], }));}from collections import defaultdictfrom datetime import datetime, timezone
def build_order_dashboard(grant_id: str) -> list: orders = fetch_orders(grant_id) shipments = fetch_shipments(grant_id) returns = fetch_returns(grant_id)
# Index shipments and returns by order_id shipments_by_order = defaultdict(list) for shipment in shipments: order_id = (shipment.get("order") or {}).get("order_id") if order_id: enrichment = shipment.get("carrier_enrichment") or {} shipments_by_order[order_id].append({ "carrier": shipment.get("carrier_name"), "tracking_number": shipment.get("tracking_number"), "delivery_status": ( enrichment.get("delivery_status") or {} ).get("description"), })
returns_by_order = defaultdict(list) for ret in returns: order_id = (ret.get("order") or {}).get("order_id") if order_id: returns_by_order[order_id].append({ "return_date": ret.get("returns_date"), "refund_total_cents": ret.get("refund_total"), })
# Build the consolidated view return [ { "order_id": order.get("order_id"), "merchant": order.get("merchant_name"), "total_cents": order.get("order_total"), "currency": order.get("currency"), "shipments": shipments_by_order.get(order.get("order_id"), []), "returns": returns_by_order.get(order.get("order_id"), []), } for order in orders ]The result is a flat list you can render directly in a dashboard, with each order carrying its shipments and returns inline.
Things to know
Section titled “Things to know”A few practical details that will save you debugging time.
Prices are in cents. Every monetary value in ExtractAI responses — order_total, unit_price, refund_total, tax_total — is in the smallest currency unit. Divide by 100 to get dollars (or the equivalent for other currencies). If you display 5863 as “$5,863.00” instead of “$58.63,” your users will notice.
Null fields are normal. ExtractAI extracts what it can from each email. If a merchant does not include a product image URL, image_url comes back as null. The same goes for discount_total, shipping_total, gift_card_total, and sometimes even merchant.name. Don’t rely on every field being populated — always use fallbacks in your code.
IMAP is not supported. ExtractAI only works with Google and Microsoft grants. If you try to query the Order Consolidation API for a grant authenticated through an IMAP connector, you will get an empty response. Filter your grant list to supported providers before making requests.
Object IDs change over time. As Nylas receives more emails for an order (confirmation, then shipping, then delivery), it may update the ExtractAI object IDs. Do not cache or reference these IDs as stable identifiers. Use order_number for orders and tracking_number for shipments as your primary keys instead.
Historical data depends on when you set up webhooks. Nylas only extracts data for grants authenticated after you configure ExtractAI webhooks. If you authenticate a grant first and enable webhooks later, you will not get up to 30 days of historical data for that grant. Authenticate your users after your webhooks are in place to get the full backfill.
Duplicate notifications happen. Nylas guarantees at-least-once delivery. Your handler might receive the same order or tracking notification more than once. Use the order_number or tracking_number as a deduplication key in your database rather than relying on webhook IDs.
The confidence_score field tells you how sure ExtractAI is. A score of 1 means high confidence in the extraction. Lower scores indicate the email was ambiguous or the merchant’s format was unusual. In practice, most notifications come through with a score of 1, but it is worth logging lower-confidence extractions separately so you can review them.
What’s next
Section titled “What’s next”You now have a working pipeline that extracts structured order data from email receipts. Here are some directions to extend it:
- Review the full ExtractAI documentation. The ExtractAI overview covers activation, notifications setup, and scoping details.
- Explore the Order Consolidation API. The Order Consolidation API guide covers pagination and response schemas for orders, shipments, and returns.
- Check supported merchants. The merchants and vendors list shows the major retailers ExtractAI supports across the US, UK, Germany, and Sweden.
- Set up webhook monitoring. The notification schemas reference has the full payload schemas for all three ExtractAI triggers.
- Build a support ticket pipeline. If you are also processing non-order email, the monitor an inbox for support tickets tutorial shows how to route incoming messages by content type.