cd /news/developer-tools/handle-message-created-webhooks-in-n… Β· home β€Ί topics β€Ί developer-tools β€Ί article
[ARTICLE Β· art-29351] src=dev.to β†— pub= topic=developer-tools verified=true sentiment=Β· neutral

Handle message.created Webhooks in Next.js

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.

read5 min views2 publishedJun 16, 2026

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 β€” a hosted mailbox your app owns, currently in beta β€” the answer is: a message.created

webhook 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.

First, point a webhook at your deployment. The subscription is a single API call with the trigger you care about:

curl --request POST \
  --url "https://api.us.nylas.com/v3/webhooks" \
  --header "Authorization: Bearer <NYLAS_API_KEY>" \
  --header "Content-Type: application/json" \
  --data '{
    "trigger_types": ["message.created"],
    "callback_url": "https://yourapp.example.com/api/webhooks/nylas"
  }'

One 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"

for Agent Accounts).

In the App Router, a single route.ts

handles both halves of the webhook contract: the GET challenge handshake and the POST notifications.

// app/api/webhooks/nylas/route.ts
import { NextRequest, NextResponse } from "next/server";

// 1. Challenge handshake β€” echo the raw value, nothing else
export async function GET(req: NextRequest) {
  const challenge = req.nextUrl.searchParams.get("challenge");
  return new Response(challenge ?? "", { status: 200 });
}

// 2. Notifications
export async function POST(req: NextRequest) {
  const payload = await req.json();
  const { object } = payload.data;

  // Hand off to your agent logic without blocking the response
  processMessage(object.grant_id, object.id).catch(console.error);

  return NextResponse.json({ ok: true }, { status: 200 });
}

async function processMessage(grantId: string, messageId: string) {
  const res = await fetch(
    `https://api.us.nylas.com/v3/grants/${grantId}/messages/${messageId}`,
    { headers: { Authorization: `Bearer ${process.env.NYLAS_API_KEY}` } },
  );
  const message = await res.json();
  // classify, draft a reply, call your LLM β€” whatever the agent does
}

Two details in there earn their keep.

The challenge echo is unforgiving. When you create the webhook (or flip it back to active), a GET request arrives with a challenge

query 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

with the raw string instead of NextResponse.json()

, which would wrap it in quotes and fail verification. Passing the handshake is also what generates your webhook_secret

for signature checks later.

Acknowledge before you think. The POST handler returns 200 immediately and lets processMessage

run 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.

The notification nests the message under data.object

, which carries the two IDs you need β€” grant_id

and the message id

β€” plus metadata like subject, sender, and a snippet:

{
  "type": "message.created",
  "data": {
    "object": {
      "id": "<MESSAGE_ID>",
      "grant_id": "<NYLAS_GRANT_ID>",
      "subject": "Hello from Nylas",
      "from": [{ "email": "sender@example.com", "name": "Sender" }],
      "snippet": "This is a sample message"
    }
  }
}

Standard notifications include the body inline, but if the payload would exceed 1 MB, the body gets stripped and the event type gains a .truncated

suffix. 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

above handles both cases by always requesting the message anyway, which is the simpler pattern when you need the full body regardless.

Delivery is at-least-once, so the same message.created

can arrive twice. For an email agent, a duplicate notification means a duplicate reply β€” embarrassing at best. Dedupe on the message id

before acting (a unique constraint in your database, or a key in Redis, both work). Ordering isn't guaranteed either: a message.created

and a later message.updated

for the same message can land out of order, so trust the fetched message state over the notification sequence.

And verify signatures. Every notification carries an X-Nylas-Signature

header β€” an HMAC-SHA256 of the raw body signed with your webhook_secret

(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.

The catch in Next.js: you need the raw body to compute the HMAC, and req.json()

consumes it. Read the text first, verify, then parse:

// app/api/webhooks/nylas/route.ts
import { NextRequest, NextResponse } from "next/server";
import crypto from "crypto";

export async function POST(req: NextRequest) {
  const raw = await req.text();
  const signature = req.headers.get("x-nylas-signature") ?? "";

  const expected = crypto
    .createHmac("sha256", process.env.NYLAS_WEBHOOK_SECRET!)
    .update(raw, "utf8")
    .digest("hex");

  const valid =
    signature.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));

  if (!valid) {
    return new Response("invalid signature", { status: 401 });
  }

  const payload = JSON.parse(raw);
  const { object } = payload.data;
  processMessage(object.grant_id, object.id).catch(console.error);

  return NextResponse.json({ ok: true }, { status: 200 });
}

The length check before timingSafeEqual

matters β€” Node throws if the buffers differ in length, and a malformed header shouldn't crash your handler. Store the webhook_secret

in an environment variable like any other credential; it never belongs in the repo.

Two smaller features of the webhook API pay off in production. When you create the subscription, you can pass notification_email_addresses

β€” 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

variant, there's message.created.cleaned

: if you have Clean Conversations configured, the body

arrives as cleaned markdown instead of raw HTML, which is a much friendlier input for an LLM than a wall of nested <div>

tags.

Latency 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

delivery; Outlook gets comparable speed through Microsoft Graph subscriptions that Nylas manages for you, with nothing to renew. The new email webhook recipe covers the full handler lifecycle, including all the delivery variants.

Deploy, 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.

What's your dedupe store of choice for webhook handlers β€” database constraint, Redis, or something else?

── more in #developer-tools 4 stories Β· sorted by recency
── more on @nylas 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/handle-message-creat…] indexed:0 read:5min 2026-06-16 Β· β€”