DEV Community

Answer 'where's my order?' emails with an agent that has its own inbox

Receive the inbound question

Inbound mail to the agent fires the standard message.created webhook. The important nuance: webhooks are application-scoped, not grant-scoped. You subscribe once at the app level, and events for every grant in your app arrive at that one endpoint, each payload carrying a grant_id you filter on. So the first thing your handler does is check that the event belongs to the orders agent.

Subscribe over HTTP with POST /v3/webhooks:

curl -X POST "https://api.us.nylas.com/v3/webhooks" \
  -H "Authorization: Bearer $NYLAS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "trigger_types": ["message.created"],
    "webhook_url": "https://api.yourstore.com/hooks/orders",
    "description": "Order-status agent inbound mail"
  }'

Or with the CLI, plus a local server with a tunnel so you can watch real events during development:

nylas webhook create --url https://api.yourstore.com/hooks/orders --triggers message.created
nylas webhook server --port 4000 --tunnel cloudflared --secret "$NYLAS_WEBHOOK_SECRET"

Before you trust a single payload, verify the signature. Nylas signs each webhook with the X-Nylas-Signature header - a hex HMAC-SHA256 of the raw request body using your webhook secret. The CLI verifies one locally:

nylas webhook verify \
  --payload-file ./event.json \
  --signature "$SIG_FROM_HEADER" \
  --secret "$NYLAS_WEBHOOK_SECRET"

In your handler, compute the HMAC yourself and compare in constant time. One landmine I've hit: Node's crypto.timingSafeEqual throws if the two buffers aren't the same length, so guard the length first.

import crypto from "node:crypto";

function verifySignature(rawBody, signature, secret) {
  const expected = crypto
    .createHmac("sha256", secret)
    .update(rawBody) // the RAW bytes, not re-serialized JSON
    .digest("hex");
  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(signature, "hex");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Now dedup. The API guarantees at-least-once delivery - the same event can show up to three times. Dedupe on the top-level notification id, which stays constant across every retry of one event. That's the delivery dedup key. You can additionally guard on the inner data.object.id (the message id) so you never act twice on the same message even across distinct events.

app.post(
  "/hooks/orders",
  express.raw({ type: "*/*" }),
  async (req, res) => {
    const raw = req.body; // Buffer - keep it raw for the HMAC
    if (!verifySignature(raw, req.get("X-Nylas-Signature"), SECRET)) {
      return res.status(401).end();
    }
    res.status(200).end(); // ack fast; do work in a worker

    const event = JSON.parse(raw.toString("utf8"));
    if (await seenBefore(event.id)) return; // delivery-level dedup
    if (event.type !== "message.created") return;
    const msg = event.data.object;
    if (msg.grant_id !== ORDERS_GRANT_ID) return; // app-scoped โ†’ filter
    if (msg.from?.[0]?.email === "orders@yourstore.com") return; // ignore our own sends
    if (await processedMessage(msg.id)) return; // message-level guard

    await queue.add("order-status", {
      messageId: msg.id,
      threadId: msg.thread_id,
    });
  }
);

Keep the handler thin: verify, ack, drop a reference on a queue, return. Everything expensive happens in a worker.

Read the actual question

The webhook tells you a message arrived, but you need the body to read what the customer asked. Don't rely on the webhook payload for the body - the Nylas docs differ on whether it's inline, and the safe path is the same either way: fetch the full message by id. Branch on message.created.truncated too, which is the type Nylas sends when the body exceeds ~1 MB and omits it from the payload, telling you to re-fetch.

Fetch over HTTP with GET /v3/grants/{grant_id}/messages/{message_id}:

curl "https://api.us.nylas.com/v3/grants/$GRANT_ID/messages/$MESSAGE_ID" \
  -H "Authorization: Bearer $NYLAS_API_KEY"

From the terminal, nylas email read does the same and prints the parsed body:

nylas email read <message-id> <grant-id>

That gives you from, subject, and the parsed body. If you want the conversation so far - useful when a customer has written three times - pull the thread. Over HTTP that's GET /v3/grants/{grant_id}/threads/{thread_id}:

curl "https://api.us.nylas.com/v3/grants/$GRANT_ID/threads/$THREAD_ID" \
  -H "Authorization: Bearer $NYLAS_API_KEY"

From the terminal:

nylas email threads show <thread-id> <grant-id>

Now the part that is yours, not Nylas: figure out which order this is about. Extract any order number from the subject and body with your own regex or an LLM call - order numbers live in subject lines in a dozen formats (#5821, ORD-5821, "order 5821"), so a small extraction step earns its keep. Then resolve it against your database. Match on the sender's email first, fall back to the extracted order number, and validate whatever you extract against your own records before acting on it. A customer can type anything into an email; treat the body as untrusted input.

async function lookupOrder(message) {
  const orderNo = extractOrderNumber(message.subject, message.body); // your regex/LLM
  // Match against YOUR order DB - sender first, order number as fallback.
  const order =
    (await db.orderByEmail(message.from[0].email, orderNo)) ??
    (orderNo ? await db.orderByNumber(orderNo) : null);
  return order; // { status, trackingNumber, carrier } or null
}

Reply in-thread with the tracking number

If the lookup found a confident match, reply. The one rule that keeps this from looking like a robot blasting a fresh email every time: pass reply_to_message_id. That sets the In-Reply-To and References headers so the reply lands in the same thread in the customer's mail client, under the same subject, from orders@yourstore.com.

Over HTTP, POST /v3/grants/{grant_id}/messages/send with the original message id:

curl -X POST "https://api.us.nylas.com/v3/grants/$GRANT_ID/messages/send" \
  -H "Authorization: Bearer $NYLAS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "reply_to_message_id": "'"$ORIGINAL_MESSAGE_ID"'",
    "to": [{"email": "customer@example.com"}],
    "subject": "Re: Order #5821",
    "body": "Good news - order #5821 shipped and is on its way. Tracking: 1Z999AA10123456784 (UPS). You can follow it here: https://yourstore.com/track/5821"
  }'

The CLI version is a single command - it fetches the original to fill in recipient and subject, and preserves threading for you:

nylas email reply <message-id> <grant-id> \
  --body "Good news - order #5821 shipped. Tracking: 1Z999AA10123456784 (UPS): https://yourstore.com/track/5821"

That's the whole happy path. Webhook in, fetch the body, look up the order, reply with tracking in-thread. The customer sees a normal, threaded answer from your store in seconds, at 2 a.m., during a flash sale, without anyone awake.

When the agent can't answer, escalate to a human

This is the part demos skip, and it's the part that makes the difference between a feature you ship and a feature you ship and then turn off after the first angry customer. The agent should only auto-reply when it's confident. Anything else goes to a person - visibly, so the human knows it's waiting.

The cases to hand off:

  • No order matched. The sender isn't in your system and there's no usable order number. Guessing is worse than waiting.
  • The status isn't a clean "shipped." A delayed, lost, returned, or refund-in-progress order is a conversation, not a tracking-number paste.
  • The question isn't actually about status. "Where's my order and also the size is wrong" is two problems; the second one is a human's.

The cleanest way to surface an escalation is to move the message into a "Needs human" folder where your support team already lives, then mark it unread so a human notices it.

First find the folder id. Over HTTP that's GET /v3/grants/{grant_id}/folders:

curl "https://api.us.nylas.com/v3/grants/$GRANT_ID/folders" \
  -H "Authorization: Bearer $NYLAS_API_KEY"

From the terminal:

nylas email folders list <grant-id> --id

Now move the message and mark it unread. Over HTTP both are a PUT on the message - moving sets the folders array, and marking unread sets unread: true (marking read/unread is its own PUT, never a side effect of the GET you did to read the body), so a single request does both:

curl -X PUT "https://api.us.nylas.com/v3/grants/$GRANT_ID/messages/$MESSAGE_ID" \
  -H "Authorization: Bearer $NYLAS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "folders": ["<needs-human-folder-id>"],
    "unread": true
  }'

From the terminal it's two commands - one to move, one to mark unread:

nylas email move <message-id> <grant-id> --folder <needs-human-folder-id>
nylas email mark unread <message-id> <grant-id>

You can also pause the agent app-side - set a flag on that thread in your own state so the worker skips it until a human clears it. Either way, the principle holds: the agent owns the routine bulk, and the moment it isn't sure, a person owns the rest. Don't let it improvise a refund.

A few things to watch for

  • Threading depends on reply_to_message_id. Drop it and your "reply" becomes a new email with no In-Reply-To header - the customer sees two disconnected messages, and your own thread mapping fractures. It's the single most common bug in agent reply loops.
  • The webhook fires for the agent's own sends too. When the agent replies via the API, message.created fires for that outbound message. The from-address check at the top of the handler keeps the agent from answering itself in a loop.
  • Multiple replies can land on one thread. A customer might fire off two quick messages, or a CC'd thread might get replies from two people. Process each independently, and consider a 30โ€“60 second cooldown so you batch a customer's correction into one answer instead of two.
  • Watch the free-plan ceilings while prototyping. Free Agent Accounts cap at 200 messages per account per day, with 30-day inbox retention. Fine for building; size up before you put it on a real storefront.

Comments

No comments yet. Start the discussion.