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?