Stripe Webhook 收据邮件 + Rovela(2026):签名校验与幂等键
在原始请求体上校验 Stripe-Signature,处理 checkout.session.completed / invoice.paid / payment_intent.succeeded,并用 Idempotency-Key = Stripe event id 通过 Rovela 发送收据。Stripe 负责收款,Rovela 负责投递。与电商订单、发票附件文区分。
Stripe 负责收款 — Rovela 负责把收据送到信箱
Stripe 会在资金状态变化时通知你的服务器。它不会替你发送可完全自定义模板、走你已验证产品域名、并由你掌控 HTTP 发送路径的支付确认 / 收据邮件。当你的 webhook 处理函数调用 POST https://api.rovela.dev/emails 时,Rovela 负责入队、DKIM 签名与投递(Send Email API、Quick Start、Authentication、Templates)。
本指南搭建一条 Stripe webhook 链路:
- 用原始请求体校验
Stripe-Signature - 处理
checkout.session.completed、invoice.paid和/或payment_intent.succeeded - 通过 Rovela 发送收据 / 支付确认邮件
- 将
Idempotency-Key= Stripe 事件id,避免 Stripe 重试导致重复发送
Rovela 不处理银行卡、不创建 Checkout Session、也不校验 Stripe 签名。 支付与事件投递归 Stripe;事件 → 客户邮箱 → 模板变量的映射归你的应用;Rovela 只负责投递。
与下列文章区分:
- 电商订单邮件 — 泛化的订单生命周期触发,不是 Stripe 签名校验,也不以 Stripe event id 做幂等键
- 带 PDF 附件的发票邮件 — 发送你已渲染好的 PDF;本文是支付事件 → HTML/模板收据,不强制附件
- 欢迎信模板 — 引导文案,不是支付确认
Stripe 做什么,Rovela 发什么
| 职责 | 归属 |
|---|---|
| Checkout、PaymentIntent、Invoice、退款 | Stripe |
Webhook 签名密钥 + constructEvent | 你的服务器 + Stripe |
| 哪些事件类型触发邮件 | 你的应用 |
| 客户邮箱、金额、币种、收据链接字段 | Stripe 事件载荷(由你映射) |
POST /emails、DKIM、队列、退信 | Rovela |
Idempotency-Key = Stripe event.id | 你的 webhook 处理函数 |
Rovela 返回 200 表示已接受并入队,不等于「已经进收件箱」(Quick Start)。
前置条件
- 在 Rovela 控制台完成发信域名验证。未验证的
from域名会被400拒绝(Quick Start)。建议使用receipts.example.com或mail.example.com。 - 形如
re_…的 API Key。生产 webhook worker 优先sending_access(Authentication)。 - Stripe 账户中配置指向你服务器的 webhook,并把该端点的签名密钥(
whsec_…)放进服务端环境变量。 STRIPE_WEBHOOK_SECRET、STRIPE_SECRET_KEY(如需回查 Stripe API)、ROVELA_API_KEY、ROVELA_FROM仅留在服务端,不要放进浏览器包或移动端。
环境变量
export STRIPE_WEBHOOK_SECRET=whsec_your_endpoint_secret
export ROVELA_API_KEY=re_your_sending_access_key
export ROVELA_FROM='Acme Receipts <receipts@mail.example.com>'
# 可选:控制台收据模板 UUID
export ROVELA_RECEIPT_TEMPLATE_ID=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee
轮换 Rovela 密钥:先创建新密钥、部署,再吊销旧密钥(Authentication)。重建 Stripe 端点后,在 Dashboard 轮换签名密钥。
为什么 Idempotency-Key 用 Stripe event id
端点过慢或返回非 2xx 时,Stripe 会重试 webhook,同一个 evt_… 可能到达多次。Rovela 的 Idempotency-Key(最长 256 字符)让 24 小时内相同发送请求的重试返回原始响应,而不会再发一封(Send Email API)。
用 event.id 最自然:一个 Stripe 事件 → 最多一封逻辑上的收据。若你自行拼接更长的键,记得截断到 256 字符(单独的 event.id 远低于上限)。
不要只按 payment_intent id 或客户邮箱做键——若多条不同事件本就该发不同邮件,会互相踩踏;有疑问时,以 event id 表示「这次 webhook 投递对应的那封邮件」。
共用 Rovela 发送封装(仅服务端)
// src/rovela.ts
const ROVELA_URL = "https://api.rovela.dev/emails";
export class RovelaError extends Error {
constructor(
public status: number,
public name: string,
message: string,
) {
super(message);
}
}
export async function sendViaRovela(opts: {
to: string;
subject?: string;
html?: string;
text?: string;
template?: { id: string; variables: Record<string, string> };
idempotencyKey: string;
replyTo?: string;
tags?: Record<string, string> | Array<{ name: string; value: string }>;
}) {
const apiKey = process.env.ROVELA_API_KEY;
const from = process.env.ROVELA_FROM;
if (!apiKey || !from) {
throw new Error("ROVELA_API_KEY and ROVELA_FROM must be set on the server");
}
const key = opts.idempotencyKey.slice(0, 256);
const body: Record<string, unknown> = {
from,
to: opts.to,
};
if (opts.template) {
// template 与 html/text 互斥 — 见 Templates 文档
body.template = opts.template;
} else {
if (!opts.html && !opts.text) {
throw new Error("html, text, or template is required");
}
if (opts.subject) body.subject = opts.subject;
if (opts.html) body.html = opts.html;
if (opts.text) body.text = opts.text;
}
if (opts.replyTo) body.reply_to = opts.replyTo;
if (opts.tags) body.tags = opts.tags;
const res = await fetch(ROVELA_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
Accept: "application/json",
"Idempotency-Key": key,
},
body: JSON.stringify(body),
});
const json = await res.json().catch(() => ({}));
if (!res.ok) {
throw new RovelaError(
res.status,
String((json as { name?: string }).name ?? "Error"),
String((json as { message?: string }).message ?? "send failed"),
);
}
return json as { object: "email"; id: string };
}
不使用控制台模板时,尽量同时提供 html 与 text。写入 HTML 或模板 variables 前,先转义任何可能受用户影响的字符串——模板替换不会做 HTML 转义(Templates)。
Express:先校验 Stripe,再发信
签名校验必须看到原始字节。在 webhook 路由上使用 express.raw(或把它排除在全局 express.json() 之外)。
// src/routes/stripe-webhook.ts
import express from "express";
import Stripe from "stripe";
import { sendViaRovela, RovelaError } from "../rovela";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-11-20.acacia", // 使用你账户锁定的 API 版本
});
export const stripeWebhook = express.Router();
stripeWebhook.post(
"/webhooks/stripe",
express.raw({ type: "application/json" }),
async (req, res) => {
const sig = req.headers["stripe-signature"];
if (!sig || Array.isArray(sig)) {
return res.status(400).send("missing Stripe-Signature");
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
req.body, // express.raw 得到的 Buffer
sig,
process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch {
return res.status(400).send("invalid signature");
}
try {
await handleStripeEvent(event);
return res.status(200).json({ received: true });
} catch (err) {
console.error("stripe webhook handler failed", err);
// 非 2xx → Stripe 会重试。发送路径较慢时,优先入队后再 200
return res.status(500).json({ error: "handler failed" });
}
},
);
function moneyLabel(amount: number | null | undefined, currency: string | null | undefined) {
if (amount == null || !currency) return "";
// Stripe 金额通常以最小货币单位计
const major = (amount / 100).toFixed(2);
return `${major} ${currency.toUpperCase()}`;
}
async function handleStripeEvent(event: Stripe.Event) {
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
if (session.payment_status !== "paid" && session.payment_status !== "no_payment_required") {
return;
}
const to = session.customer_details?.email || session.customer_email;
if (!to) return;
const amount = moneyLabel(session.amount_total, session.currency);
const ref =
session.payment_intent && typeof session.payment_intent === "string"
? session.payment_intent
: session.id;
await sendReceipt({
to,
amountLabel: amount,
reference: ref,
eventId: event.id,
kind: "checkout",
});
break;
}
case "invoice.paid": {
const invoice = event.data.object as Stripe.Invoice;
const to = invoice.customer_email;
if (!to) return;
await sendReceipt({
to,
amountLabel: moneyLabel(invoice.amount_paid, invoice.currency),
reference: invoice.number || invoice.id,
eventId: event.id,
kind: "invoice",
receiptUrl: invoice.hosted_invoice_url || invoice.invoice_pdf || undefined,
});
break;
}
case "payment_intent.succeeded": {
const pi = event.data.object as Stripe.PaymentIntent;
// 若已监听 Checkout,通常避免同一笔款再发一封产品收据
if (pi.metadata?.skip_rovela_receipt === "1") return;
const to = pi.receipt_email;
if (!to) return;
await sendReceipt({
to,
amountLabel: moneyLabel(pi.amount_received || pi.amount, pi.currency),
reference: pi.id,
eventId: event.id,
kind: "payment_intent",
});
break;
}
default:
break;
}
}
async function sendReceipt(opts: {
to: string;
amountLabel: string;
reference: string;
eventId: string;
kind: string;
receiptUrl?: string;
}) {
const templateId = process.env.ROVELA_RECEIPT_TEMPLATE_ID;
const subject = opts.amountLabel
? `已收到付款 — ${opts.amountLabel}`
: "已收到付款";
const html = `
<p>感谢你的付款。</p>
<p><b>金额:</b> ${escapeHtml(opts.amountLabel || "—")}</p>
<p><b>参考号:</b> ${escapeHtml(opts.reference)}</p>
${
opts.receiptUrl
? `<p><a href="${escapeHtml(opts.receiptUrl)}">查看收据</a></p>`
: ""
}
<p>如非本人操作,请联系支持。</p>
`;
const text = [
"感谢你的付款。",
`金额:${opts.amountLabel || "—"}`,
`参考号:${opts.reference}`,
opts.receiptUrl ? `收据:${opts.receiptUrl}` : "",
"如非本人操作,请联系支持。",
]
.filter(Boolean)
.join("\n");
try {
if (templateId) {
await sendViaRovela({
to: opts.to,
template: {
id: templateId,
variables: {
amount: opts.amountLabel || "—",
reference: opts.reference,
receipt_url: opts.receiptUrl || "",
kind: opts.kind,
},
},
idempotencyKey: opts.eventId,
tags: { stripe_event: opts.eventId, kind: opts.kind },
});
} else {
await sendViaRovela({
to: opts.to,
subject,
html,
text,
idempotencyKey: opts.eventId,
tags: { stripe_event: opts.eventId, kind: opts.kind },
});
}
} catch (err) {
if (err instanceof RovelaError) {
console.error("Rovela send failed", err.status, err.name, err.message);
}
throw err;
}
}
function escapeHtml(s: string) {
return s
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
}
挂载顺序
import express from "express";
import { stripeWebhook } from "./routes/stripe-webhook";
const app = express();
app.use(stripeWebhook); // 先挂 raw body 路由
app.use(express.json()); // 其余 API 再用 JSON
若全局 JSON 解析先跑,constructEvent 会失败,因为签名覆盖的是 Stripe 发出的原始字节。
Next.js App Router 路由
// app/api/webhooks/stripe/route.ts
import Stripe from "stripe";
import { NextRequest, NextResponse } from "next/server";
import { sendViaRovela } from "@/lib/rovela";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-11-20.acacia",
});
export const runtime = "nodejs";
export async function POST(req: NextRequest) {
const rawBody = await req.text();
const sig = req.headers.get("stripe-signature");
if (!sig) {
return new NextResponse("missing signature", { status: 400 });
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
rawBody,
sig,
process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch {
return new NextResponse("invalid signature", { status: 400 });
}
if (event.type === "checkout.session.completed") {
const session = event.data.object as Stripe.Checkout.Session;
const to = session.customer_details?.email || session.customer_email;
if (to) {
const amount =
session.amount_total != null && session.currency
? `${(session.amount_total / 100).toFixed(2)} ${session.currency.toUpperCase()}`
: "";
await sendViaRovela({
to,
subject: amount ? `已收到付款 — ${amount}` : "已收到付款",
html: `<p>感谢你的付款。</p><p><b>金额:</b> ${amount || "—"}</p>`,
text: `感谢你的付款。\n金额:${amount || "—"}`,
idempotencyKey: event.id,
});
}
}
return NextResponse.json({ received: true });
}
可选:控制台收据模板
先创建并发布模板(需要 full_access 或控制台),webhook 里再用 template.id + variables 发送(Templates):
curl -X POST https://api.rovela.dev/templates \
-H "Authorization: Bearer re_your_full_access_key" \
-H "Content-Type: application/json" \
-d '{
"name": "Payment receipt",
"subject": "已收到付款 — {{amount}}",
"html": "<p>感谢支付 <b>{{amount}}</b>。</p><p>参考号:{{reference}}</p>",
"text": "感谢支付 {{amount}}。参考号:{{reference}}"
}'
然后 POST /templates/:id/publish。生产 webhook worker 可继续只用 sending_access——它们只调用 POST /emails。
注意:同一次发送不能把 template 与 html/text 混用;POST /emails/batch 会直接拒绝 template(Send Email API)。
三种事件怎么选
| 事件 | 典型用途 |
|---|---|
checkout.session.completed | Hosted Checkout / Payment Links — 常作为「客户完成付款」的单一钩子 |
invoice.paid | 订阅与 Stripe Invoice — 可附带 hosted invoice / PDF 链接 |
payment_intent.succeeded | 自定义 PaymentElement / Charges — 若依赖邮箱请在 PaymentIntent 上设置 receipt_email |
每个产品路径尽量只选一个主事件,避免同一笔款收到两封 Rovela 收据。Stripe Dashboard 若开启了自带收据邮件,那与 Rovela 无关——若 Rovela 是唯一品牌渠道,可关闭 Stripe 自动收据,否则客户可能两封都收到。
生产检查清单
- 在原始 body 上校验签名,不要拿已 parse 的 JSON 做 HMAC。
- 尽快返回 2xx。若 Rovela 或数据库偏慢,先入队
{ eventId, payload }再异步发送;幂等键仍用event.id。 - 凡由 webhook 驱动的 Rovela 发送都带
Idempotency-Key: event.id(Send Email API)。 - worker 使用
sending_access,绝不暴露给客户端(Authentication)。 - 发送 html + text,或已发布的 template,不要空正文。
- 写入 HTML / 模板变量前转义用户可控字段(Templates)。
- 需要运维可见性时用
GET /emails/:id或 Rovela webhook;发送接口的200只表示入队(Quick Start)。
本文不覆盖
- 退款(
charge.refunded)或失败付款(payment_intent.payment_failed)——模式相同、文案不同;幂等键仍用event.id - 自行生成并附加 PDF——见带 PDF 附件的发票邮件
- 不经 Stripe 的购物车 / OMS 订单邮件——见电商订单邮件
- Rovela 入站或投递 webhook——与 Stripe 签名密钥无关
小结
Stripe 证明事件真实且付款成功;你的 webhook 把事件映射成收件人与收据字段;Rovela 的 POST /emails 配合 Idempotency-Key = Stripe event.id,即使 Stripe 重试也只投递一封确认信。支付留在 Stripe,投递交给 Rovela,密钥留在服务端。