Every e-commerce order generates a trail of email — order confirmation, shipping notification, delivery update, return label, refund receipt. The data you actually need is locked inside unstructured HTML, and writing your own parser means maintaining merchant-specific templates for Amazon, Walmart, Target, and thousands of other retailers, each with its own format that can change without notice.
ExtractAI handles that part for you. It watches the inbox, detects order/shipment/return email, and pushes structured JSON through webhooks: order numbers, line items, carrier names, tracking links, refund totals. No parsing, no regex, no template maintenance. 30,000+ merchants out of the box, Google and Microsoft only.
The pipeline
Section titled “The pipeline”inbound order email ─▶ ExtractAI detects + extracts ─▶ message.intelligence.order webhookinbound shipping email ─▶ ExtractAI detects + extracts ─▶ message.intelligence.tracking webhookinbound return email ─▶ ExtractAI detects + extracts ─▶ message.intelligence.return webhook │ ▼ webhook handler routes by type │ ▼ store order/shipment/return records │periodic backfill ─▶ Order Consolidation API ─▶ join orders + shipments + returns by order_idThree webhook triggers feed the real-time path. The Order Consolidation API gives you the same data on demand — useful for backfills or building a unified dashboard.
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)
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.
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.
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)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”- Prices are in cents. Every monetary value in ExtractAI responses —
order_total,unit_price,refund_total,tax_total— is in the smallest currency unit.5863means $58.63, not $5,863.00. Divide by 100 before you render. This is the bug you will ship if you don’t read this bullet. - Null fields are normal. Not every merchant includes every field.
image_url,discount_total,shipping_total,gift_card_total, and sometimesmerchant.namecome back null. Always use fallbacks; never assume a field is populated. - IMAP is not supported. ExtractAI works only with Google and Microsoft grants. The Order Consolidation API returns an empty response for IMAP grants. Filter your grant list before you query.
- Object IDs are not stable. As Nylas receives more email for an order (confirmation, then shipping, then delivery), it may update the ExtractAI object IDs. Use
order_numberandtracking_numberas your primary keys — never the ExtractAI ID. - Historical backfill depends on setup order. Nylas only extracts data for grants authenticated after you configure ExtractAI webhooks. Enable the webhook before connecting users if you want the up-to-30-day backfill on day one.
- Don’t ship without dedup. At-least-once delivery means the same order notification can arrive twice. Dedupe on
order_numberortracking_number, not webhook ID. - Watch the
confidence_score. Most extractions come back with1(high confidence). Lower scores signal an ambiguous email or unusual merchant format. Log them separately so you can review without polluting the main flow.
Next steps
Section titled “Next steps”- Monitor an inbox for support tickets — the non-ExtractAI counterpart for routing inbound email by content
- Sync email contacts to a CRM — pull data out of email for a different downstream system
- ExtractAI overview — activation, scoping, and notification setup
- Order Consolidation API — pagination and response schemas for orders, shipments, returns
- Supported merchants and vendors — retailer coverage across the US, UK, Germany, and Sweden