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.
Subscribing the endpoint
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).
The route handler
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.
Reading the payload
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.
Two production habits
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.
Signature verification in the App Router
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.
Failure alerts and delivery variants
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.
Prove it works
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?
Top comments (0)