DEV Community

Qasim
Qasim

Posted on

Build a webhook-driven email pipeline for your AI agent

Most "AI email" tutorials end with a while True loop that polls an inbox every thirty seconds, runs the new messages through a model, and sends a reply. It demos fine. Then you put it in front of real traffic and the cracks show up immediately: you're burning API calls to fetch nothing 99% of the time, your reaction latency is bounded by your poll interval, and the moment you scale to more than one inbox the polling cost multiplies.

Polling an agent's inbox is wasteful. Webhooks are the right primitive for this. The mailbox already knows the instant a message lands — there's no reason to keep asking. What you actually want is a pipeline: inbound mail fans out to a verified ingest endpoint, lands on a queue, and gets picked up by workers that drive your agent runtime and send the reply. This post is about that architecture end to end — not a single feature, but the whole flow, with the parts that bite you in production (idempotency, retries, ordering, backpressure) called out honestly.

I work on the Nylas CLI, so the terminal commands below are the exact ones I reach for when I'm wiring this up. I'll show the curl HTTP call and the CLI equivalent for every concrete step, because you'll use both: curl in your provisioning scripts, the CLI when you're poking at a live account.

The mental model: an Agent Account is just a grant

Before any of the pipeline matters, here's the one abstraction that makes the whole thing simple. An Agent Account is a Nylas grant — it has a grant_id, an inbox, an email address on a domain you own, and it speaks every grant-scoped endpoint you already know: Messages, Threads, Folders, Drafts, Attachments, Calendars, Events, Contacts, Webhooks. There's no OAuth token to refresh, no provider-specific quirks, no separate SDK. If you've built against a connected Gmail or Microsoft grant before, the data plane is identical. Nothing new to learn there.

What's different is that the agent is a participant. It has its own address — support@yourcompany.com — rather than borrowing a human's inbox. That's what makes the event-driven design clean: one grant, one inbox, one webhook stream, no shared-mailbox coordination problem.

Provision one with POST /v3/connect/custom:

curl --request POST \
  --url "https://api.us.nylas.com/v3/connect/custom" \
  --header "Authorization: Bearer $NYLAS_API_KEY" \
  --header "Content-Type: application/json" \
  --data '{
    "provider": "nylas",
    "settings": { "email": "support@yourcompany.com" },
    "name": "Support Agent"
  }'
Enter fullscreen mode Exit fullscreen mode

Or the CLI, which creates the nylas connector for you if it doesn't exist yet and auto-provisions a default workspace and policy:

nylas agent account create support@yourcompany.com --name "Support Agent"
Enter fullscreen mode Exit fullscreen mode

The settings.email has to be on a domain you've registered with Nylas (a custom domain, or a *.nylas.email trial subdomain to start). New domains warm over roughly four weeks before you push real volume through them. Hold onto the grant_id you get back — every endpoint below is scoped to it.

The architecture, component by component

Here's the flow. Each arrow is a place where something can go wrong, and naming them is half the work:

inbound mail
   │  message.created  (one app-scoped endpoint, all grants)
   ▼
[ ingest endpoint ]  ── verify X-Nylas-Signature (HMAC-SHA256)
   │                 ── return 200 immediately
   │  enqueue {notification_id, grant_id, message_id, thread_id}
   ▼
[ queue ]  ── at-least-once; dedup on notification id
   │
   ▼
[ worker ]  ── full message via API (re-fetch on .truncated)
   │        ── per-thread lock
   ▼
[ agent runtime ]  ── generate the reply
   │
   ▼
[ send ]  ── POST .../messages/send  ── delivered / bounced / complaint / rejected
Enter fullscreen mode Exit fullscreen mode

The guiding principle: the ingest endpoint does as little as possible. It verifies the signature, drops a small job on the queue, and returns 200. Everything expensive — re-fetching a truncated message, calling a model, sending — happens in a worker behind the queue. This isn't just tidy; it's what keeps Nylas from retrying you. If your endpoint is slow to ack, Nylas assumes the delivery failed and sends the event again.

One thing to get straight before you wire anything up: webhooks in Nylas are application-scoped, not grant-scoped. You subscribe once at the app level with POST /v3/webhooks, and events for every grant in the application — every Agent Account you've provisioned — arrive at that single endpoint. Each payload carries a grant_id, and routing to the right agent is your handler's job, not a subscription setting. So if you run several agents (support@, sales@, scheduling@), they share one webhook stream and you fan out on grant_id downstream.

Step 1: subscribe to the webhook

You want message.created for inbound, and the four deliverability triggers for outbound visibility — message.delivered, message.bounced, message.complaint, and message.rejected. (Agent Accounts emit all four; connected grants don't.)

curl --request POST \
  --url "https://api.us.nylas.com/v3/webhooks" \
  --header "Authorization: Bearer $NYLAS_API_KEY" \
  --header "Content-Type: application/json" \
  --data '{
    "trigger_types": [
      "message.created",
      "message.delivered",
      "message.bounced",
      "message.complaint",
      "message.rejected"
    ],
    "webhook_url": "https://pipeline.yourcompany.com/webhooks/nylas",
    "description": "Support agent inbound + deliverability"
  }'
Enter fullscreen mode Exit fullscreen mode

The CLI version, which I use constantly because it's one line:

nylas webhook create \
  --url https://pipeline.yourcompany.com/webhooks/nylas \
  --triggers message.created,message.delivered,message.bounced,message.complaint,message.rejected \
  --description "Support agent inbound + deliverability"
Enter fullscreen mode Exit fullscreen mode

Both return a webhook record with a webhook_secret. Save it somewhere your ingest endpoint can read it — that secret is the signing key for the next step.

If you want to develop against real webhooks without deploying, the CLI ships a local receiver. nylas webhook server --tunnel cloudflared --secret <your-webhook-secret> stands up an HTTP server, opens a Cloudflare tunnel, and verifies the signature on each event as it arrives. It's a dev helper — not your production ingest endpoint — but it's the fastest way to see the actual payload shape before you write a line of handler code.

Step 2: verify the signature, then ack fast

Every webhook Nylas sends includes an X-Nylas-Signature header. It's a hex-encoded HMAC-SHA256 of the raw request body, signed with your webhook_secret. This is a real security step, not boilerplate — without it, anyone who learns your webhook URL can POST fake message.created events and make your agent reply to mail that never arrived.

The thing that trips people up: verify against the exact bytes of the body. If your framework parses the JSON and re-serializes it before you hash, the bytes change and the signature won't match. Read the raw body first, verify, then parse.

import crypto from "node:crypto";

const WEBHOOK_SECRET = process.env.NYLAS_WEBHOOK_SECRET;

function isValidSignature(rawBody, signature) {
  const expected = crypto
    .createHmac("sha256", WEBHOOK_SECRET)
    .update(rawBody) // exact bytes, not re-serialized JSON
    .digest("hex");
  const a = Buffer.from(expected, "utf8");
  const b = Buffer.from(signature ?? "", "utf8");
  // timingSafeEqual throws on length mismatch, so guard the lengths first.
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// Mount this with a raw-body parser so `req.rawBody` is the untouched bytes.
app.post("/webhooks/nylas", (req, res) => {
  const signature = req.get("X-Nylas-Signature");
  if (!isValidSignature(req.rawBody, signature)) return res.status(401).end();

  // Ack first. Do real work behind the queue.
  res.status(200).end();

  const event = JSON.parse(req.rawBody.toString());
  if (event.type !== "message.created") return; // deliverability events route elsewhere

  queue.enqueue({
    notificationId: event.id, // top-level envelope id — the dedup key
    grantId: event.data.object.grant_id,
    messageId: event.data.object.id,
    threadId: event.data.object.thread_id,
  });
});
Enter fullscreen mode Exit fullscreen mode

Use a constant-time comparison (timingSafeEqual) rather than ===, and guard the buffer lengths first — timingSafeEqual throws if the two buffers differ in length, so a forged header of the wrong size would otherwise crash your handler instead of cleanly returning 401. Note the order: verify, return 200, then enqueue. The 200 is what tells Nylas the delivery succeeded.

While you're building the handler, you can sanity-check your verification logic offline with the CLI instead of replaying live events:

nylas webhook verify \
  --payload-file ./captured-event.json \
  --signature "$CAPTURED_SIGNATURE" \
  --secret "$NYLAS_WEBHOOK_SECRET"
Enter fullscreen mode Exit fullscreen mode

nylas webhook verify runs the same HMAC check locally so you can confirm your secret and your byte-handling are right before you trust your own code in production.

Step 3: enqueue — and embrace at-least-once

Here's the honest part nobody puts in the demo. Nylas guarantees at-least-once delivery. When your endpoint doesn't ack within the window, Nylas retries the same notification up to two more times — three attempts total — and a transient hiccup or a slow ack triggers exactly that. If your agent processes two copies, it replies twice. Twice is a bug your users will notice.

So the queue isn't just for buffering. It's the boundary where you make the pipeline idempotent. The key detail: dedup on the top-level notification id, not the message id. Every notification carries an envelope id that stays constant across all three delivery attempts of the same event — that's your webhook-delivery dedup key. The inner data.object.id identifies the message, which is a different thing; you can layer it in as a secondary "have I already acted on this message?" check, but the primary idempotency key is the notification id:

async function enqueueOnce(job) {
  // Atomic check-and-set on the top-level notification id.
  // Redis: SET key 1 NX EX 86400.
  // Postgres: INSERT ... ON CONFLICT DO NOTHING, check rows affected.
  const isNew = await dedup.setIfAbsent(`seen:${job.notificationId}`, {
    ttlSeconds: 86_400, // 24h — long enough to catch a late redelivery
  });
  if (!isNew) return; // same notification redelivered, drop it
  await queue.push(job);
}
Enter fullscreen mode Exit fullscreen mode

The TTL matters: a redelivered webhook hours later still has to be caught, so 24–48 hours is a safe default. After that window, a repeat notification id is almost certainly a bug, not a redelivery — and you'll want it logged, not silently swallowed.

A note on the payload itself: a standard message.created includes the message body inline, so for most messages you have what you need right there. Nylas only strips the body when the payload would exceed ~1 MB, and when it does the trigger type becomes message.created.truncated. A resilient worker doesn't assume either way — it branches on the type and re-fetches the full message by id when the body is missing, which is also exactly what you want after a .truncated event. Carry the IDs on the queue regardless, so the worker can always fetch the canonical message rather than trusting a payload that might be partial.

Step 4: the worker — fetch, lock, decide

The worker pulls a job and works from the canonical message — using the inline body when it's there, and fetching by id when the event was .truncatedthen decides. Fetch with the Messages endpoint:

curl --request GET \
  --url "https://api.us.nylas.com/v3/grants/$GRANT_ID/messages/$MESSAGE_ID" \
  --header "Authorization: Bearer $NYLAS_API_KEY"
Enter fullscreen mode Exit fullscreen mode

Same thing from the CLI when you're inspecting an account by hand:

nylas email read <message-id> support@yourcompany.com
Enter fullscreen mode Exit fullscreen mode

To reconstruct conversation context before replying, pull the thread. The webhook handed you a thread_id; read the whole thread so the agent answers in context:

nylas email threads show <thread-id> support@yourcompany.com
Enter fullscreen mode Exit fullscreen mode

Now the two checks that keep a distributed worker pool honest. First, filter out the agent's own outbound — sending a message also fires message.created, and an agent that replies to itself is a reply storm waiting to happen. Second, take a per-thread lock so two workers that raced past the dedup check in the same millisecond don't both generate a reply:

async function handle(job) {
  const msg = await fetchMessage(job.grantId, job.messageId);

  // Skip the agent's own sends — outbound also fires message.created.
  if (msg.from?.[0]?.email === AGENT_EMAIL) return;

  const lock = await locks.acquire(`thread:${job.threadId}`, { ttlMs: 30_000 });
  if (!lock.acquired) return; // another worker owns this thread

  try {
    // Double-check: did a reply already go out since this arrived?
    const thread = await fetchThread(job.grantId, job.threadId);
    const last = thread.latestMessage;
    if (last?.from?.[0]?.email === AGENT_EMAIL) return;

    const reply = await agentRuntime.run(msg, thread);
    await send(job.grantId, job.messageId, reply);
  } finally {
    await lock.release();
  }
}
Enter fullscreen mode Exit fullscreen mode

Dedup and locking solve different problems and you need both. Dedup catches the same event delivered twice. The lock catches two workers processing the same event at once. Drop either one and the duplicate replies come back under load. There's a full treatment of this in the prevent duplicate replies cookbook recipe — it's the single most important page to read before you ship.

Step 5: send from the agent

The reply goes out through the same grant. Send directly so you get open/click tracking and so the deliverability webhooks fire:

curl --request POST \
  --url "https://api.us.nylas.com/v3/grants/$GRANT_ID/messages/send" \
  --header "Authorization: Bearer $NYLAS_API_KEY" \
  --header "Content-Type: application/json" \
  --data '{
    "reply_to_message_id": "<message-id>",
    "to": [{ "email": "customer@example.com" }],
    "subject": "Re: your question",
    "body": "Thanks for reaching out — here is what I found..."
  }'
Enter fullscreen mode Exit fullscreen mode

reply_to_message_id is what keeps the response in the same thread. From the CLI, nylas email reply does the threading for you by fetching the original to populate recipients and subject:

nylas email reply <message-id> support@yourcompany.com \
  --body "Thanks for reaching out — here is what I found..."
Enter fullscreen mode Exit fullscreen mode

If you omit from, Nylas defaults it to the Agent Account's own address — which is what you want here.

Ordering, retries, and backpressure

Three systems concerns the happy-path code hides.

Ordering. Webhooks are not strictly ordered. Two messages on the same thread can arrive at your workers out of order, and at-least-once delivery means you can't assume the sequence you see matches the sequence they were sent. If reply order matters, serialize per thread — the same per-thread lock above doubles as an ordering gate, since only one worker touches a thread at a time. Don't try to order across the whole mailbox; order within the unit that actually needs it, which is the thread.

Retries with backoff. When a worker fails — the model API times out, the send 5xxs — don't drop the job and don't hot-loop it. Retry with exponential backoff and a cap (say 1s, 4s, 16s, then dead-letter). The notification-id dedup record stops Nylas's own redeliveries, and the in-lock thread re-check stops a worker retry from double-sending even if the first attempt actually succeeded before failing to ack. Idempotency is what makes retries safe; without it, every retry is a coin flip on a duplicate.

Backpressure. This is why the queue earns its place. A burst of inbound — a newsletter reply-all, a status-page incident — can arrive faster than your agent runtime can think. With the queue in the middle, the ingest endpoint keeps acking 200 at line rate while workers drain at whatever pace the model allows. No queue, and that burst either melts your runtime or makes you ack slowly enough that Nylas starts retrying, which adds load. Size your worker concurrency to the model's throughput, let the queue absorb the spikes, and add a per-thread outbound rate cap as a circuit breaker so a logic bug can't turn into a send loop.

One note on pipeline state: Agent Accounts don't support custom metadata, so you can't stash your dedup keys, lock state, or retry counters on the Nylas objects. Keep all of that in your own store or queue. That's the right place for it anyway — it's your operational state, not the mailbox's.

What's next

You now have the full loop: inbound message.created → verify the signature → enqueue with dedup → worker fetches and locks → agent runtime → send, with ordering, retries, and backpressure handled at the seams rather than bolted on later. The data plane is just grant-scoped endpoints, so anything you can do with a normal grant — drafts, folders, calendars, attachments — slots into the same pipeline.

AI-answer pages for agents

When this post is published, link AI agents and crawlers to the retrieval-ready version on cli.nylas.com:

Top comments (0)