{"slug": "handle-message-created-webhooks-in-next-js", "title": "Handle message.created Webhooks in Next.js", "summary": "A developer built a Next.js webhook handler for Nylas message.created events, which must acknowledge the webhook within 10 seconds. The handler uses a GET route for the challenge handshake and a POST route that returns 200 immediately before processing the message asynchronously. The implementation ensures the challenge echo returns the raw string without JSON wrapping to avoid verification failure.", "body_md": "What actually happens in the first ten seconds after someone emails your AI agent? If you've built the agent on a [Nylas Agent Account](https://developer.nylas.com/docs/v3/getting-started/agent-accounts/) — a hosted mailbox your app owns, currently in beta — the answer is: a `message.created`\n\nwebhook hits your server, and you have exactly 10 seconds to acknowledge it. In a Next.js app, that handler is one route file. Let's build it properly.\n\nFirst, point a webhook at your deployment. The subscription is a single API call with the trigger you care about:\n\n```\ncurl --request POST \\\n  --url \"https://api.us.nylas.com/v3/webhooks\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\" \\\n  --header \"Content-Type: application/json\" \\\n  --data '{\n    \"trigger_types\": [\"message.created\"],\n    \"callback_url\": \"https://yourapp.example.com/api/webhooks/nylas\"\n  }'\n```\n\nOne subscription covers every grant on the application — the same payload shape arrives whether mail lands in an agent-owned mailbox or a connected Gmail or Outlook account. If your app handles both, branch on the grant's provider (`\"nylas\"`\n\nfor Agent Accounts).\n\nIn the App Router, a single `route.ts`\n\nhandles both halves of the webhook contract: the GET challenge handshake and the POST notifications.\n\n``` js\n// app/api/webhooks/nylas/route.ts\nimport { NextRequest, NextResponse } from \"next/server\";\n\n// 1. Challenge handshake — echo the raw value, nothing else\nexport async function GET(req: NextRequest) {\n  const challenge = req.nextUrl.searchParams.get(\"challenge\");\n  return new Response(challenge ?? \"\", { status: 200 });\n}\n\n// 2. Notifications\nexport async function POST(req: NextRequest) {\n  const payload = await req.json();\n  const { object } = payload.data;\n\n  // Hand off to your agent logic without blocking the response\n  processMessage(object.grant_id, object.id).catch(console.error);\n\n  return NextResponse.json({ ok: true }, { status: 200 });\n}\n\nasync function processMessage(grantId: string, messageId: string) {\n  const res = await fetch(\n    `https://api.us.nylas.com/v3/grants/${grantId}/messages/${messageId}`,\n    { headers: { Authorization: `Bearer ${process.env.NYLAS_API_KEY}` } },\n  );\n  const message = await res.json();\n  // classify, draft a reply, call your LLM — whatever the agent does\n}\n```\n\nTwo details in there earn their keep.\n\n**The challenge echo is unforgiving.** When you create the webhook (or flip it back to active), a GET request arrives with a `challenge`\n\nquery parameter. You have 10 seconds to return that exact value in the body — no quotes, no JSON wrapper, no chunked encoding. Get it wrong and the endpoint is marked failed on the first try, with no retry. That's why the handler returns a bare `Response`\n\nwith the raw string instead of `NextResponse.json()`\n\n, which would wrap it in quotes and fail verification. Passing the handshake is also what generates your `webhook_secret`\n\nfor signature checks later.\n\n**Acknowledge before you think.** The POST handler returns 200 immediately and lets `processMessage`\n\nrun unawaited. Notifications share the same 10-second window, and an LLM call or a slow database write will blow through it. Acknowledging first keeps a slow downstream call from timing out the webhook delivery.\n\nThe notification nests the message under `data.object`\n\n, which carries the two IDs you need — `grant_id`\n\nand the message `id`\n\n— plus metadata like subject, sender, and a snippet:\n\n```\n{\n  \"type\": \"message.created\",\n  \"data\": {\n    \"object\": {\n      \"id\": \"<MESSAGE_ID>\",\n      \"grant_id\": \"<NYLAS_GRANT_ID>\",\n      \"subject\": \"Hello from Nylas\",\n      \"from\": [{ \"email\": \"sender@example.com\", \"name\": \"Sender\" }],\n      \"snippet\": \"This is a sample message\"\n    }\n  }\n}\n```\n\nStandard notifications include the body inline, but if the payload would exceed 1 MB, the body gets stripped and the event type gains a `.truncated`\n\nsuffix. You don't subscribe to that variant separately — your code just needs a branch that re-fetches the full message when it sees it. The fetch in `processMessage`\n\nabove handles both cases by always requesting the message anyway, which is the simpler pattern when you need the full body regardless.\n\nDelivery is at-least-once, so the same `message.created`\n\ncan arrive twice. For an email agent, a duplicate notification means a duplicate reply — embarrassing at best. Dedupe on the message `id`\n\nbefore acting (a unique constraint in your database, or a key in Redis, both work). Ordering isn't guaranteed either: a `message.created`\n\nand a later `message.updated`\n\nfor the same message can land out of order, so trust the fetched message state over the notification sequence.\n\nAnd verify signatures. Every notification carries an `X-Nylas-Signature`\n\nheader — an HMAC-SHA256 of the raw body signed with your `webhook_secret`\n\n(the one generated when the challenge handshake passed). An unverified endpoint that triggers email sends is an open invitation, so recompute and compare before trusting anything in the payload.\n\nThe catch in Next.js: you need the *raw* body to compute the HMAC, and `req.json()`\n\nconsumes it. Read the text first, verify, then parse:\n\n``` js\n// app/api/webhooks/nylas/route.ts\nimport { NextRequest, NextResponse } from \"next/server\";\nimport crypto from \"crypto\";\n\nexport async function POST(req: NextRequest) {\n  const raw = await req.text();\n  const signature = req.headers.get(\"x-nylas-signature\") ?? \"\";\n\n  const expected = crypto\n    .createHmac(\"sha256\", process.env.NYLAS_WEBHOOK_SECRET!)\n    .update(raw, \"utf8\")\n    .digest(\"hex\");\n\n  const valid =\n    signature.length === expected.length &&\n    crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));\n\n  if (!valid) {\n    return new Response(\"invalid signature\", { status: 401 });\n  }\n\n  const payload = JSON.parse(raw);\n  const { object } = payload.data;\n  processMessage(object.grant_id, object.id).catch(console.error);\n\n  return NextResponse.json({ ok: true }, { status: 200 });\n}\n```\n\nThe length check before `timingSafeEqual`\n\nmatters — Node throws if the buffers differ in length, and a malformed header shouldn't crash your handler. Store the `webhook_secret`\n\nin an environment variable like any other credential; it never belongs in the repo.\n\nTwo smaller features of the webhook API pay off in production. When you create the subscription, you can pass `notification_email_addresses`\n\n— Nylas emails those addresses if your endpoint starts failing, which is often the first signal that a deploy broke the handler. And beyond the `.truncated`\n\nvariant, there's `message.created.cleaned`\n\n: if you have Clean Conversations configured, the `body`\n\narrives as cleaned markdown instead of raw HTML, which is a much friendlier input for an LLM than a wall of nested `<div>`\n\ntags.\n\nLatency is provider-dependent but good by default. For Google grants with the right scopes, wiring up Google Pub/Sub feeds push events instead of polling and shaves seconds off `message.created`\n\ndelivery; Outlook gets comparable speed through Microsoft Graph subscriptions that Nylas manages for you, with nothing to renew. The [new email webhook recipe](https://developer.nylas.com/docs/cookbook/use-cases/build/new-email-webhook/) covers the full handler lifecycle, including all the delivery variants.\n\nDeploy, create the webhook against your production URL, then send a message from your phone to the agent's address. Watch the route logs: GET challenge, then a POST within seconds of the message landing. Once that round-trip works, the rest of the agent — classification, drafting, sending the reply from the same grant — is regular application code.\n\nWhat's your dedupe store of choice for webhook handlers — database constraint, Redis, or something else?", "url": "https://wpnews.pro/news/handle-message-created-webhooks-in-next-js", "canonical_source": "https://dev.to/qasim157/handle-messagecreated-webhooks-in-nextjs-4e80", "published_at": "2026-06-16 11:05:53+00:00", "updated_at": "2026-06-16 11:17:39.211671+00:00", "lang": "en", "topics": ["developer-tools", "ai-agents"], "entities": ["Nylas", "Next.js", "Nylas Agent Account"], "alternates": {"html": "https://wpnews.pro/news/handle-message-created-webhooks-in-next-js", "markdown": "https://wpnews.pro/news/handle-message-created-webhooks-in-next-js.md", "text": "https://wpnews.pro/news/handle-message-created-webhooks-in-next-js.txt", "jsonld": "https://wpnews.pro/news/handle-message-created-webhooks-in-next-js.jsonld"}}