DEV Community

Qasim Muhammad
Qasim Muhammad

Posted on

Local Webhook Development for Agent Inboxes

The first webhook integration I ever built failed before it received a single notification. The handler was solid — JSON parsing, logging, the lot — but the subscription itself showed up as failed seconds after I created it. The reason turned out to be boring: my endpoint was http://localhost:3000, and the platform calling it lives on the internet, where my laptop does not. Every email-agent developer hits some version of this on day one, so here's the local development loop that actually works.

The setup: you're building an agent on an Agent Account — a hosted mailbox your app owns, currently in beta — and inbound mail fires a message.created webhook you need to handle. The webhook URL must be public HTTPS, because Nylas calls it from the internet within 10 seconds to verify it. Localhost fails that check by definition.

Step 0: A mailbox to develop against

You need an address that mail can actually land on. The trial-domain route means no DNS work at all: register a *.nylas.email subdomain from the Dashboard, then mint an account on it:

nylas agent account create test@your-application.nylas.email
Enter fullscreen mode Exit fullscreen mode

The CLI prints the new grant ID — every webhook payload you receive will carry it, and every follow-up fetch will use it. For production you'd register your own domain with MX and TXT records, but for a local dev loop the trial domain is live immediately and disposable afterward.

Step 1: Put a tunnel in front of your dev server

Any HTTPS tunnel works — ngrok, Cloudflare Tunnel, your own reverse proxy. The shape is the same:

ngrok http 3000
# Forwarding: https://f3a1-203-0-113-7.ngrok-free.app -> http://localhost:3000
Enter fullscreen mode Exit fullscreen mode

That public URL is what you register, with /webhooks/nylas (or whatever your route is) appended.

Step 2: Write the handler before you subscribe

Order matters here, and this is the part the failed-subscription story teaches. The moment you create a webhook, a verification request arrives — so the handler has to be up and tunneled first. The verification is a GET request carrying a challenge query parameter, and your endpoint has 10 seconds to echo the exact value back in a 200 OK body. The rules are strict: raw value only, no quotes, no JSON wrapper, no chunked encoding. Get any of it wrong and the endpoint is marked failed on the first attempt, with no retry — you delete and recreate.

A minimal Express handler covers both the handshake and the notifications:

import express from "express";

const app = express();
app.use(express.json());

// Challenge handshake — echo the raw value within 10 seconds
app.get("/webhooks/nylas", (req, res) => {
  res.status(200).send(req.query.challenge);
});

// Notifications land here once verification passes
app.post("/webhooks/nylas", (req, res) => {
  res.status(200).end(); // acknowledge first
  console.dir(req.body, { depth: null }); // inspect everything while developing
});

app.listen(3000);
Enter fullscreen mode Exit fullscreen mode

A passing handshake also generates your webhook_secret — hold onto it, it's the key for verifying the X-Nylas-Signature HMAC on every notification once you harden the handler.

Step 3: Subscribe with the tunnel URL

One CLI command or one API call:

nylas webhook create \
  --url https://f3a1-203-0-113-7.ngrok-free.app/webhooks/nylas \
  --triggers message.created
Enter fullscreen mode Exit fullscreen mode

If the handshake passes, the webhook goes active. Now send an email from your phone to the agent's address and watch the POST arrive in your terminal — usually within seconds of the message landing.

Step 4: Actually read the payloads

This is the payoff of local development: you see real notification bodies at full fidelity before writing any parsing logic. Things to notice while you're staring at them:

  • The message nests under data.object, which carries the grant_id and message id you'll need for follow-up fetches. The application_id sits on data, one level up.
  • Standard payloads include the message body inline — but if a payload would exceed 1 MB, the body is stripped and the event type gets a .truncated suffix. You don't subscribe to that variant separately; your handler just needs a branch that re-fetches via GET /v3/grants/{grant_id}/messages/{message_id}.
  • Send yourself two messages quickly and you may see the same notification twice. Delivery is at-least-once, and ordering isn't guaranteed — a created and a later updated can land out of order. Seeing this locally, before production, is exactly why this loop is worth setting up.

The res.status(200).end() before the logging isn't an accident either. Notifications share the 10-second response window, and acknowledging first means a slow downstream call — say, an LLM request you add later — never times out the delivery.

Step 5: Harden before you forget

Once payload parsing works, add the signature check — locally, while you can still test failure paths by hand. Every notification carries an X-Nylas-Signature header: a hex-encoded HMAC-SHA256 of the raw request body, signed with the webhook_secret from the handshake. The catch in Express is that express.json() consumes the raw body, so capture it in the parser's verify hook:

import crypto from "crypto";

app.use(express.json({
  verify: (req, _res, buf) => { req.rawBody = buf; },
}));

app.post("/webhooks/nylas", (req, res) => {
  const expected = crypto
    .createHmac("sha256", process.env.NYLAS_WEBHOOK_SECRET)
    .update(req.rawBody)
    .digest("hex");
  if (expected !== req.headers["x-nylas-signature"]) {
    return res.status(401).end();
  }
  res.status(200).end();
  console.dir(req.body, { depth: null });
});
Enter fullscreen mode Exit fullscreen mode

Curl your own tunnel with a garbage signature and confirm the 401. That two-minute test is much cheaper than discovering in production that anyone on the internet can make your agent send email.

The tunnel-rotation chore

Free tunnel tiers mint a new URL on every restart, and your webhook subscription points at the old one. Three ways to deal with it:

  1. Update the existing webhook each time the URL changes: nylas webhook update <WEBHOOK_ID> (find the ID with nylas webhook list).
  2. Pay for a stable tunnel domain and never think about it again. For anything beyond a weekend hack, this is worth it.
  3. Let failures page you — pass notification_email_addresses when creating the webhook and Nylas emails you when deliveries start failing, which is usually how you find out the tunnel died while you were at lunch.

Either way, remember each new URL re-triggers the challenge handshake — handler up first, always.

For the full handler lifecycle, including signature verification and the delivery variants, the new email webhook recipe is the reference I'd keep open in a tab.

Tonight's exercise: get the handshake passing, then email your agent and diff two consecutive payloads for the same thread. The structure you see is the schema you'll be coding against for the life of the project — better to meet it in your terminal than in production logs.

Top comments (0)