所有文章
邮件送达、退信和投诉事件信封通过签名隧道流入 localhost 上的 Next.js 路由。
ResendNext.jsemail webhookslocalhost

使用 Next.js 在本地测试 Resend Webhook

要在 Next.js 中本地测试 Resend Webhooks,请创建读取原始正文的 App Router POST 路由,使用您的 Resend 签名密钥验证其 Svix 标头,并在 Resend 仪表板中注册 HTTPS 隧道 URL。 通过Resend发送邮件,然后处理真实的 email.sent, email.delivered, email.bounced, 或 email.complained 本地主机上的事件。

Resend Webhook 告诉您的应用程序什么

API 响应表明电子邮件已被接受并不能证明电子邮件已到达收件人。交付是异步发生的。 Resend Webhook 可让您的应用程序在原始发送请求完成后更新消息状态、抑制错误地址、报告退回邮件并对投诉做出反应。的 官方 Resend webhook 文档 列出事件类型和仪表板设置。

本地 Webhook 测试应覆盖整个状态机,而不仅仅是 POST 是否到达您的路由。将每个事件的电子邮件 ID 与发送时创建的记录相关联。将状态视为转换:接受、发送、交付、延迟、退回、投诉、打开或点击(如果适用)。稍后的重复项不得覆盖更有用的状态或触发相同的警报两次。

创建 Next.js App Router 路由

安装为签名格式维护的验证程序:

npm install svix

然后创建一个节点运行时路由。 Resend 标志原体,所以使用 request.text() 解析之前恰好一次。

// app/api/webhooks/resend/route.ts
import { Webhook } from 'svix';

export const runtime = 'nodejs';

export async function POST(request: Request) {
  const payload = await request.text();
  const headers = {
    'svix-id': request.headers.get('svix-id') ?? '',
    'svix-timestamp': request.headers.get('svix-timestamp') ?? '',
    'svix-signature': request.headers.get('svix-signature') ?? '',
  };

  let event: ResendEvent;
  try {
    const webhook = new Webhook(process.env.RESEND_WEBHOOK_SECRET!);
    event = webhook.verify(payload, headers) as ResendEvent;
  } catch {
    return Response.json({ error: 'Invalid webhook signature' }, { status: 400 });
  }

  await enqueueResendEvent({
    deliveryId: headers['svix-id'],
    event,
  });
  return Response.json({ received: true });
}

签名密钥属于此 Webhook 端点,通常以提供商特定的前缀开头。将其从 Resend Webhook 设置复制到忽略的本地环境文件中,例如 .env.local。它不是用于发送电子邮件的 Resend API 密钥。

为什么三个 Svix 标头很重要

  • svix-id 唯一标识一次交付,是最好的幂等性密钥。
  • svix-timestamp 将签名绑定到时间,允许验证者拒绝超出其容忍范围的陈旧请求。
  • svix-signature 可以包含一个或多个用于验证主体的版本化签名。

除非有令人信服的理由,否则不要通过拆分标头字符串来实现此协议。 SDK 处理编码、多重签名和时间戳检查。 Resend 明确建议使用签名密钥和这些标头进行验证。越深 签名验证指南 解释了为什么原始字节和定时安全检查很重要。

公开 Next.js 并注册端点

  1. 运行 npm run dev 并确认应用程序侦听端口 3000。
  2. 开始 npx portpreview 3000 在另一个终端。
  3. 在 Resend 中,创建一个 webhook,其端点为 https://YOUR-TUNNEL.portpreview.dev/api/webhooks/resend
  4. 仅选择您的应用程序处理的电子邮件事件。
  5. 将端点的签名密钥复制到 RESEND_WEBHOOK_SECRET 并重新启动 Next.js 以便加载变量。
  6. 使用经过验证的域发送消息并检查到达本地路由的事件。

保持会话的公共 URL 稳定。如果隧道起点发生更改,请在再次测试之前编辑 Resend 端点。即使 localhost 本身运行正常,使用旧 URL 配置的端点也无法到达您的新进程。

使用类型化事件调度程序

Webhook 有效负载应进入一个狭窄的调度程序。验证必填字段并使无法识别的事件类型可观察,而不将其视为服务器故障。

async function processEvent(event: ResendEvent) {
  switch (event.type) {
    case 'email.delivered':
      await markDelivered(event.data.email_id, event.created_at);
      break;
    case 'email.bounced':
      await markBounced(event.data.email_id, event.data.bounce?.message);
      await suppressIfPermanent(event.data);
      break;
    case 'email.complained':
      await suppressRecipients(event.data.to);
      await alertCompliance(event.data.email_id);
      break;
    default:
      await recordUnhandledEvent(event);
  }
}

保持有效负载类型与当前 Resend 架构保持一致,而不是假设每个事件都具有相同的数据。例如,退回详细信息和收件人列表可能仅与某些事件相关。保存事件类型、提供商电子邮件 ID、事件时间戳和最小编辑负载以供支持调查。

在测试重试之前使处理幂等

Webhook 系统在实践中提供至少一次传递行为。在数据库提交之后、提供程序收到您的 200 响应之前,可能会发生超时。然后,提供商会重试您已申请的请求。使用 svix-id 作为唯一的交付密钥,并将其插入状态更改时的同一事务中。

await db.transaction(async (tx) => {
  const inserted = await tx.webhookDelivery.insertOnce({
    provider: 'resend',
    deliveryId,
  });
  if (!inserted) return;
  await applyEmailEvent(tx, event);
});

不要仅通过电子邮件 ID 进行重复数据删除,因为一封电子邮件合法地接收多种事件类型。根据您的数据模型,保留交付级别唯一密钥和状态转换规则。阅读 Webhook 重试和幂等模式 在将事件连接到计费、抑制或客户通知之前。

快速返回,不丢失事件

签名验证在请求路径中是适当的;缓慢的业务工作则不然。保留或排队已验证的事件,然后返回 2xx。如果您在任何持久写入之前返回,则进程崩溃可能会丢失该事件。如果您等待多个远程 API,您的端点可能会超时并邀请重试。数据库收件箱表通常是最简单的本地和生产设计。

生成有用的测试事件

已发送并已交付

从经过验证的域发送到您控制的地址。记录发送 API 返回的电子邮件 ID 并确认传入事件更新同一行。传送时间因收件人服务器而异,因此不要假设事件立即到达或按简单的顺序到达。

弹跳

使用 Resend 记录的测试地址或测试功能,而不是发明流量到不相关的域。验证永久性故障是否会抑制将来的邮件,而临时情况是否遵循您的重试策略。不要自动抑制每个延迟事件。

投诉

投诉处理既是可交付性也是合规性逻辑。确保重复的 Webhook 不会创建重复的警报,并确保根据您的策略将受影响的收件人排除在以后的活动之外。

排查 Resend Webhook 故障

签名验证总是失败

确认已加载端点的签名密钥(而不是 API 密钥)。使用 await request.text(),不解析和重新字符串化 JSON,并传递所有三个 Svix 标头及其确切值。更改后重启开发服务器 .env.local

路由返回404或405

App Router 路由文件必须命名 route.ts 在预期的 URL 段下方并导出 POST。检查中间件是否重写了对区域设置或登录页面的隧道请求。使用curl 测试公共URL 并检查实际响应。

尽管处理成功,Resend仍显示重试

检查每个成功的分支是否立即返回 2xx。数据库更新后引发的错误可能会产生 500 和重复重试。使处理具有事务性和幂等性,然后检查响应延迟。

事件已到达但无法链接到电子邮件

保留原始 Resend 发送响应中的提供商电子邮件 ID。不要依赖主题行或收件人地址作为标识符。这些字段既不唯一,也不够稳定,无法进行关联。

重放的捕获未通过时间戳验证

当通过普通验证器重放旧的签名请求时,这是预期的:它的时间戳可能超出允许的容限。首选提供商重新交付(如果有)。对于隔离的业务逻辑测试,验证一次,保存经过清理的事件固定装置,然后单独测试调度程序。的 重播指南 解释了这个边界。

电子邮件事件测试的安全和隐私

  • 绝不暴露 RESEND_API_KEY 或源、浏览器捆绑包、屏幕截图或请求日志中的端点签名密钥。
  • 在解析或保留事件之前进行验证。
  • 从共享隧道捕获中编辑收件人、主题、标头和消息元数据。
  • 对原始 Webhook 负载应用保留限制;仅存储支持和合规性所需的内容。
  • 使用与生产环境不同的本地端点机密,并在删除测试端点时轮换它。

最终设计在部署后应该以相同的方式工作:公共 HTTPS 端点、原始主体验证、持久幂等性、快速确认和异步状态处理。有关 App Router 特定原始主体的详细信息,请参阅 Next.js webhook 本地主机指南

常见问题

如何在 Next.js 中本地测试 Resend Webhooks?
创建 App Router POST 路由,使用 Svix 标头和端点签名密钥验证原始正文,通过 HTTPS 公开端口 3000,并在 Resend 中注册该公共 URL。
Resend webhook 是否应该在 Next.js 中使用 request.json() ?
验证之前不行。读取await request.text(),使签名字节保持不变,使用Svix进行验证,并使用SDK返回的已验证事件。
Resend Webhook 签名密钥与 API 密钥相同吗?
否。API 密钥授权发送请求。每个 Webhook 端点都有一个签名密钥,用于验证传入事件;两者分开存放。
如何防止重复的 Resend Webhook 处理?
将 Svix-id 存储在唯一约束下,并在同一事务中应用该事件。不要仅通过电子邮件 ID 进行重复数据删除,因为一封电子邮件包含多个有效事件。