惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

D
DataBreaches.Net
MongoDB | Blog
MongoDB | Blog
GbyAI
GbyAI
L
LangChain Blog
B
Blog
博客园 - 三生石上(FineUI控件)
Martin Fowler
Martin Fowler
博客园 - 【当耐特】
Recent Announcements
Recent Announcements
P
Proofpoint News Feed
U
Unit 42
Last Week in AI
Last Week in AI
WordPress大学
WordPress大学
有赞技术团队
有赞技术团队
雷峰网
雷峰网
Microsoft Security Blog
Microsoft Security Blog
T
The Blog of Author Tim Ferriss
爱范儿
爱范儿
小众软件
小众软件
I
InfoQ
G
Google Developers Blog
大猫的无限游戏
大猫的无限游戏
人人都是产品经理
人人都是产品经理
C
Check Point Blog

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant
Handle message.created Webhooks in Next.js
Qasim Muhammad · 2026-06-16 · via DEV Community

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?