← 返回博客

Stripe Webhook 收据邮件 + Rovela(2026):签名校验与幂等键

在原始请求体上校验 Stripe-Signature,处理 checkout.session.completed / invoice.paid / payment_intent.succeeded,并用 Idempotency-Key = Stripe event id 通过 Rovela 发送收据。Stripe 负责收款,Rovela 负责投递。与电商订单、发票附件文区分。

更新于 2026年11月4日
Stripe Webhook 收据邮件 + Rovela(2026):签名校验与幂等键

Stripe 负责收款 — Rovela 负责把收据送到信箱

Stripe 会在资金状态变化时通知你的服务器。它不会替你发送可完全自定义模板、走你已验证产品域名、并由你掌控 HTTP 发送路径的支付确认 / 收据邮件。当你的 webhook 处理函数调用 POST https://api.rovela.dev/emails 时,Rovela 负责入队、DKIM 签名与投递(Send Email API、Quick Start、Authentication、Templates)。

本指南搭建一条 Stripe webhook 链路:

  1. 用原始请求体校验 Stripe-Signature
  2. 处理 checkout.session.completed、invoice.paid 和/或 payment_intent.succeeded
  3. 通过 Rovela 发送收据 / 支付确认邮件
  4. 将 Idempotency-Key = Stripe 事件 id,避免 Stripe 重试导致重复发送

Rovela 不处理银行卡、不创建 Checkout Session、也不校验 Stripe 签名。 支付与事件投递归 Stripe;事件 → 客户邮箱 → 模板变量的映射归你的应用;Rovela 只负责投递。

与下列文章区分:

Stripe 做什么,Rovela 发什么

职责归属
Checkout、PaymentIntent、Invoice、退款Stripe
Webhook 签名密钥 + constructEvent你的服务器 + Stripe
哪些事件类型触发邮件你的应用
客户邮箱、金额、币种、收据链接字段Stripe 事件载荷(由你映射)
POST /emails、DKIM、队列、退信Rovela
Idempotency-Key = Stripe event.id你的 webhook 处理函数

Rovela 返回 200 表示已接受并入队,不等于「已经进收件箱」(Quick Start)。

前置条件

  1. 在 Rovela 控制台完成发信域名验证。未验证的 from 域名会被 400 拒绝(Quick Start)。建议使用 receipts.example.com 或 mail.example.com。
  2. 形如 re_… 的 API Key。生产 webhook worker 优先 sending_access(Authentication)。
  3. Stripe 账户中配置指向你服务器的 webhook,并把该端点的签名密钥(whsec_…)放进服务端环境变量。
  4. 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, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;");
}

挂载顺序

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.completedHosted Checkout / Payment Links — 常作为「客户完成付款」的单一钩子
invoice.paid订阅与 Stripe Invoice — 可附带 hosted invoice / PDF 链接
payment_intent.succeeded自定义 PaymentElement / Charges — 若依赖邮箱请在 PaymentIntent 上设置 receipt_email

每个产品路径尽量只选一个主事件,避免同一笔款收到两封 Rovela 收据。Stripe Dashboard 若开启了自带收据邮件,那与 Rovela 无关——若 Rovela 是唯一品牌渠道,可关闭 Stripe 自动收据,否则客户可能两封都收到。

生产检查清单

  1. 在原始 body 上校验签名,不要拿已 parse 的 JSON 做 HMAC。
  2. 尽快返回 2xx。若 Rovela 或数据库偏慢,先入队 { eventId, payload } 再异步发送;幂等键仍用 event.id。
  3. 凡由 webhook 驱动的 Rovela 发送都带 Idempotency-Key: event.id(Send Email API)。
  4. worker 使用 sending_access,绝不暴露给客户端(Authentication)。
  5. 发送 html + text,或已发布的 template,不要空正文。
  6. 写入 HTML / 模板变量前转义用户可控字段(Templates)。
  7. 需要运维可见性时用 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,密钥留在服务端。