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.
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
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.
Any HTTPS tunnel works — ngrok, Cloudflare Tunnel, your own reverse proxy. The shape is the same:
ngrok http 3000
That public URL is what you register, with /webhooks/nylas
(or whatever your route is) appended.
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);
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.
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
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.
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:
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..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}
.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.
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 });
});
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.
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:
nylas webhook update <WEBHOOK_ID>
(find the ID with nylas webhook list
).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.