{"slug": "local-webhook-development-for-agent-inboxes", "title": "Local Webhook Development for Agent Inboxes", "summary": "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.", "body_md": "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`\n\n, 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.\n\nThe 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`\n\nwebhook 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.\n\nYou need an address that mail can actually land on. The trial-domain route means no DNS work at all: register a `*.nylas.email`\n\nsubdomain from the Dashboard, then mint an account on it:\n\n```\nnylas agent account create test@your-application.nylas.email\n```\n\nThe 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.\n\nAny HTTPS tunnel works — ngrok, Cloudflare Tunnel, your own reverse proxy. The shape is the same:\n\n``` php\nngrok http 3000\n# Forwarding: https://f3a1-203-0-113-7.ngrok-free.app -> http://localhost:3000\n```\n\nThat public URL is what you register, with `/webhooks/nylas`\n\n(or whatever your route is) appended.\n\nOrder 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`\n\nquery parameter, and your endpoint has 10 seconds to echo the exact value back in a `200 OK`\n\nbody. 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.\n\nA minimal Express handler covers both the handshake and the notifications:\n\n``` python\nimport express from \"express\";\n\nconst app = express();\napp.use(express.json());\n\n// Challenge handshake — echo the raw value within 10 seconds\napp.get(\"/webhooks/nylas\", (req, res) => {\n  res.status(200).send(req.query.challenge);\n});\n\n// Notifications land here once verification passes\napp.post(\"/webhooks/nylas\", (req, res) => {\n  res.status(200).end(); // acknowledge first\n  console.dir(req.body, { depth: null }); // inspect everything while developing\n});\n\napp.listen(3000);\n```\n\nA passing handshake also generates your `webhook_secret`\n\n— hold onto it, it's the key for verifying the `X-Nylas-Signature`\n\nHMAC on every notification once you harden the handler.\n\nOne CLI command or one API call:\n\n```\nnylas webhook create \\\n  --url https://f3a1-203-0-113-7.ngrok-free.app/webhooks/nylas \\\n  --triggers message.created\n```\n\nIf 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.\n\nThis 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:\n\n`data.object`\n\n, which carries the `grant_id`\n\nand message `id`\n\nyou'll need for follow-up fetches. The `application_id`\n\nsits on `data`\n\n, one level up.`.truncated`\n\nsuffix. 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}`\n\n.`created`\n\nand a later `updated`\n\ncan land out of order. Seeing this locally, before production, is exactly why this loop is worth setting up.The `res.status(200).end()`\n\n*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.\n\nOnce payload parsing works, add the signature check — locally, while you can still test failure paths by hand. Every notification carries an `X-Nylas-Signature`\n\nheader: a hex-encoded HMAC-SHA256 of the raw request body, signed with the `webhook_secret`\n\nfrom the handshake. The catch in Express is that `express.json()`\n\nconsumes the raw body, so capture it in the parser's `verify`\n\nhook:\n\n``` python\nimport crypto from \"crypto\";\n\napp.use(express.json({\n  verify: (req, _res, buf) => { req.rawBody = buf; },\n}));\n\napp.post(\"/webhooks/nylas\", (req, res) => {\n  const expected = crypto\n    .createHmac(\"sha256\", process.env.NYLAS_WEBHOOK_SECRET)\n    .update(req.rawBody)\n    .digest(\"hex\");\n  if (expected !== req.headers[\"x-nylas-signature\"]) {\n    return res.status(401).end();\n  }\n  res.status(200).end();\n  console.dir(req.body, { depth: null });\n});\n```\n\nCurl 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.\n\nFree tunnel tiers mint a new URL on every restart, and your webhook subscription points at the old one. Three ways to deal with it:\n\n`nylas webhook update <WEBHOOK_ID>`\n\n(find the ID with `nylas webhook list`\n\n).`notification_email_addresses`\n\nwhen 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.\n\nFor the full handler lifecycle, including signature verification and the delivery variants, the [new email webhook recipe](https://developer.nylas.com/docs/cookbook/use-cases/build/new-email-webhook/) is the reference I'd keep open in a tab.\n\nTonight'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.", "url": "https://wpnews.pro/news/local-webhook-development-for-agent-inboxes", "canonical_source": "https://dev.to/qasim157/local-webhook-development-for-agent-inboxes-jpl", "published_at": "2026-06-16 11:06:07+00:00", "updated_at": "2026-06-16 11:17:20.880755+00:00", "lang": "en", "topics": ["developer-tools", "ai-agents", "large-language-models"], "entities": ["Nylas", "ngrok", "Cloudflare Tunnel", "Express", "Agent Account"], "alternates": {"html": "https://wpnews.pro/news/local-webhook-development-for-agent-inboxes", "markdown": "https://wpnews.pro/news/local-webhook-development-for-agent-inboxes.md", "text": "https://wpnews.pro/news/local-webhook-development-for-agent-inboxes.txt", "jsonld": "https://wpnews.pro/news/local-webhook-development-for-agent-inboxes.jsonld"}}