Local Webhook Development for Agent Inboxes A developer building email-agent webhooks found that localhost endpoints fail because the platform requires a public HTTPS URL for verification. The solution uses a trial domain from Nylas and an HTTPS tunnel like ngrok, with a handler that must be running before the webhook is created to pass the challenge handshake within 10 seconds. 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 https://developer.nylas.com/docs/v3/getting-started/agent-accounts/ — 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: php ngrok http 3000 Forwarding: https://f3a1-203-0-113-7.ngrok-free.app - http://localhost: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: python 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: python 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