Paddle Billing Webhook 邮件 + Rovela(2026):Paddle-Signature 与幂等键
校验 Paddle-Signature(对 ts:rawBody 做 HMAC-SHA256),处理 transaction.completed / subscription.created,解析买家邮箱,并用 Idempotency-Key = paddle-transaction.completed-{txn_id} 或 paddle-subscription.created-{sub_id} 通过 Rovela POST /emails 发送品牌确认信。Paddle 负责结账,Rovela 负责投递。与 Stripe、Shopify、Lemon Squeezy webhook 文区分。
Paddle Billing 负责结账 — Rovela 负责投递确认信
Paddle Billing 会在交易完成处理或订阅创建时通知你的服务器。Paddle 作为商户记录方(MoR)发出的顾客收据,并不是从你自己的已验证产品域名、用你的模板、带着你的 Idempotency-Key 发出的自定义 HTTP 投递。当你的 webhook 处理器调用 POST https://api.rovela.dev/emails 时,Rovela 负责入队、DKIM 签名并投递这封品牌事务邮件(发送邮件 API、快速开始、鉴权、模板)。
本指南会接好一条 Paddle Billing webhook,做到:
- 校验
Paddle-Signature头(用通知目的地密钥对ts:rawBody做 HMAC-SHA256)(签名校验) - 处理
transaction.completed和/或subscription.created - 解析买家邮箱(Paddle 交易负载提供的是
customer_id,不是邮箱本身) - 通过 Rovela 发送购买或订阅确认信
- 设置稳定的
Idempotency-Key,避免 Paddle 重试导致重复投递
Rovela 不会创建 Paddle 结账、注册通知目的地,也不会校验 Paddle-Signature。 商务、税务与事件投递归 Paddle;把 webhook 映射到顾客邮箱与模板变量归你的应用;Rovela 只负责投递。
与下列文章区分:
- Stripe Webhook 收据邮件 —
Stripe-Signature+ Checkout/Invoice/PaymentIntent,不是 Paddle Billing 的event_type负载 - Shopify 订单 Webhook 邮件 —
X-Shopify-Hmac-SHA256+orders/create,不是transaction.completed - Lemon Squeezy Webhook 确认邮件 — 仅对原始 body 做
X-Signature+order_created/subscription_created,不是ts:bodyHMAC
Paddle 负责什么,Rovela 发送什么
| 关注点 | 负责方 |
|---|---|
| 结账、目录、订阅、税务、MoR 收据 | Paddle Billing |
通知目的地 URL + endpoint secret + Paddle-Signature | 你的服务器 + Paddle |
| 选择哪些事件触发邮件 | 你的应用 |
买家邮箱解析(custom_data、Customers API 或 customer.* 事件) | 你的应用 |
POST /emails、DKIM、队列、退信 | Rovela |
Idempotency-Key = 事件类型 + 资源 id | 你的 webhook 处理器 |
Rovela 返回 200 表示已接受并入队,不代表「已经进收件箱」(快速开始)。
前置条件
- 在 Rovela 控制台有一个已验证发信域名。未验证的
from域名会以400拒绝(快速开始)。推荐使用orders.example.com或mail.example.com这类子域。 - 形如
re_…的 API 密钥。webhook 工作进程优先使用sending_access(鉴权)。 - 一个类型为
url的 Paddle 通知目的地,至少订阅transaction.completed与subscription.created,并保存其 endpoint secret key(控制台或GET /notification-settings/{id})(Webhook 工作原理、签名校验)。 - 准备好买家邮箱策略:在结账时写入
customData、用customer_id调 Paddle Customers API,和/或本地存储customer.created/customer.updated。仅凭交易 webhook 拿不到邮箱地址。 - 把
PADDLE_WEBHOOK_SECRET、可选的PADDLE_API_KEY、ROVELA_API_KEY、ROVELA_FROM只放在服务器上。不要放进浏览器包、营销站或移动 App。
环境变量
export PADDLE_WEBHOOK_SECRET=pdl_ntfset_..._your_endpoint_secret
# 可选:custom_data 没有邮箱时,用 Customers API 解析
export PADDLE_API_KEY=your_paddle_api_key
export PADDLE_API_BASE=https://api.paddle.com # 或 https://sandbox-api.paddle.com
export ROVELA_API_KEY=re_your_sending_access_key
export ROVELA_FROM='Acme Orders <orders@mail.example.com>'
# 可选:控制台模板 UUID
export ROVELA_TXN_TEMPLATE_ID=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee
export ROVELA_SUB_TEMPLATE_ID=ffffffff-1111-2222-3333-444444444444
轮换 Rovela 密钥时:先创建新密钥、部署,再吊销旧密钥(鉴权)。重建 endpoint 时,在 Paddle → Developer Tools → Notifications 中编辑目的地以轮换 endpoint secret。
为什么 Paddle 场景需要 Idempotency-Key
Paddle 期望你尽快返回成功响应;若 endpoint 失败或超时,会按退避重试投递(Webhook 工作原理)。每条通知还有唯一的 event_id / notification_id —— 用它们做处理去重;同时把 Rovela 的发送键钉在你关心的商务资源上(每笔 completed 交易一封确认,每个订阅一封欢迎信)。
Rovela 的 Idempotency-Key 头(最长 256 字符)会让 24 小时内相同发送的重试返回原响应,而不是再发一封(发送邮件 API)。
推荐键:
| 键 | 使用场景 |
|---|---|
paddle-transaction.completed-{data.id} | 每个 txn_… 一封购买确认 |
paddle-subscription.created-{data.id} | 每个 sub_… 一封订阅欢迎 |
paddle-{event_type}-{data.id} | 以后扩展其他事件的通用模式 |
注意重叠: 循环结账常会同时发出 transaction.completed 与 subscription.created(往往还有更早的 transaction.paid)。建议:
transaction.completed→ 购买 / 收据确认(总额、若有则发票号)subscription.created→ 订阅入职(套餐/条目、账单管理链接),使用不同模板与幂等键
或者产品邮件路径只监听其中一个事件。确认「内部处理已完成」的邮件优先用 transaction.completed 而不是 transaction.paid —— Paddle 文档把 completed 定义为内部处理(含自动收款结账上挂上 subscription_id)完成的时点(transaction.completed)。
共用 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 转义(模板)。
在原始请求体上校验 Paddle-Signature
Paddle 发送形如 ts=<unix>;h1=<hex> 的 Paddle-Signature。把签名载荷建成 `${ts}:${rawBody}`,用目的地密钥做 HMAC-SHA256,再用 timingSafeEqual 比较 h1。哈希前不要重新序列化 JSON。官方 SDK 默认还会拒绝时间戳晚于约 五秒 的事件,以限制重放(签名校验)。
// src/paddle-verify.ts
import crypto from "node:crypto";
const DEFAULT_TOLERANCE_SEC = 5;
export function verifyPaddleSignature(
rawBody: string | Buffer,
signatureHeader: string | null | undefined,
secret: string,
toleranceSec = DEFAULT_TOLERANCE_SEC,
): boolean {
if (!signatureHeader || !secret) return false;
const parts = Object.fromEntries(
signatureHeader.split(";").map((p) => {
const i = p.indexOf("=");
return i === -1 ? [p, ""] : [p.slice(0, i), p.slice(i + 1)];
}),
) as { ts?: string; h1?: string };
const ts = parts.ts;
const h1 = parts.h1;
if (!ts || !h1) return false;
const tsNum = Number(ts);
if (!Number.isFinite(tsNum)) return false;
const age = Math.abs(Math.floor(Date.now() / 1000) - tsNum);
if (age > toleranceSec) return false;
const body =
typeof rawBody === "string" ? rawBody : rawBody.toString("utf8");
const signedPayload = `${ts}:${body}`;
const digest = crypto
.createHmac("sha256", secret)
.update(signedPayload, "utf8")
.digest("hex");
const expected = Buffer.from(digest, "utf8");
const received = Buffer.from(h1, "utf8");
if (expected.length !== received.length) return false;
return crypto.timingSafeEqual(expected, received);
}
若已使用 @paddle/paddle-node-sdk,生产环境可优先用 paddle.webhooks.unmarshal(rawBody, secret, signature) —— 同样必须保留原始 body。
解析买家邮箱
Paddle Billing 的 transaction / subscription webhook 实体带有 customer_id(ctm_…),而不是 email。按此顺序解析:
- 你在结账 / 交易上设置的
custom_data(例如{ "app_user_id": "usr_123", "email": "buyer@example.com" }) - 你自己的数据库,用
custom_data.app_user_id或此前由customer.created/customer.updated存下的customer_id → email映射 - 使用服务端 Paddle API 密钥调用 Customers API
GET /customers/{customer_id}(sandbox 或 live 基址)
永远不要编造地址。若无法解析 to,可先确认 webhook(或入队重试)而不调用 Rovela —— 不要猜测。
// src/paddle-email.ts
type PaddleCustomer = { data?: { email?: string | null } };
export async function resolveBuyerEmail(opts: {
customerId: string | null | undefined;
customData: Record<string, unknown> | null | undefined;
}): Promise<string | null> {
const fromCustom = [
opts.customData?.email,
opts.customData?.customer_email,
opts.customData?.buyer_email,
].find((v) => typeof v === "string" && v.includes("@"));
if (typeof fromCustom === "string") return fromCustom.trim();
// 可选:在此用 custom_data.app_user_id 查你自己的用户表
if (!opts.customerId || !process.env.PADDLE_API_KEY) return null;
const base = process.env.PADDLE_API_BASE || "https://api.paddle.com";
const res = await fetch(`${base}/customers/${opts.customerId}`, {
headers: {
Authorization: `Bearer ${process.env.PADDLE_API_KEY}`,
"Content-Type": "application/json",
},
});
if (!res.ok) return null;
const json = (await res.json()) as PaddleCustomer;
const email = json.data?.email;
return email && email.includes("@") ? email : null;
}
Express:先校验,再发送
webhook 路由使用 express.raw,让签名校验看到与 Paddle 签名时相同的字节。
// src/routes/paddle-webhook.ts
import express from "express";
import { verifyPaddleSignature } from "../paddle-verify";
import { resolveBuyerEmail } from "../paddle-email";
import { sendViaRovela, RovelaError } from "../rovela";
type PaddleEvent = {
event_id?: string;
event_type?: string;
notification_id?: string;
data?: {
id?: string;
status?: string;
customer_id?: string | null;
subscription_id?: string | null;
invoice_number?: string | null;
currency_code?: string;
custom_data?: Record<string, unknown> | null;
details?: {
totals?: { grand_total?: string; total?: string; currency_code?: string };
};
items?: Array<{
price?: { name?: string | null; description?: string; product_id?: string };
quantity?: number;
}>;
};
};
export const paddleWebhook = express.Router();
paddleWebhook.post(
"/webhooks/paddle",
express.raw({ type: "application/json" }),
async (req, res) => {
const secret = process.env.PADDLE_WEBHOOK_SECRET;
if (!secret) {
return res.status(500).send("webhook secret not configured");
}
const rawBody: Buffer = Buffer.isBuffer(req.body)
? req.body
: Buffer.from(String(req.body ?? ""), "utf8");
const signature = req.headers["paddle-signature"];
const sig = Array.isArray(signature) ? signature[0] : signature;
// 若工作进程时钟 / 队列跳转较慢可略放宽;官方 SDK 默认约 5 秒 —— 同步处理时生产环境宜收紧。
if (!verifyPaddleSignature(rawBody, sig, secret, 5)) {
return res.status(400).send("invalid signature");
}
let event: PaddleEvent;
try {
event = JSON.parse(rawBody.toString("utf8")) as PaddleEvent;
} catch {
return res.status(400).send("invalid json");
}
try {
await handlePaddleEvent(event);
return res.status(200).json({ received: true });
} catch (err) {
console.error("paddle webhook handler failed", err);
// 非 2xx → Paddle 会重试。发送较慢时优先入队再 200。
return res.status(500).json({ error: "handler failed" });
}
},
);
async function handlePaddleEvent(event: PaddleEvent) {
const data = event.data;
const id = data?.id;
if (!id || !event.event_type) return;
switch (event.event_type) {
case "transaction.completed": {
if (data.status && data.status !== "completed") return;
const to = await resolveBuyerEmail({
customerId: data.customer_id,
customData: data.custom_data,
});
if (!to) {
console.warn("skip mail: no buyer email for", id);
return;
}
const product =
data.items?.[0]?.price?.name ||
data.items?.[0]?.price?.description ||
"your purchase";
const grand =
data.details?.totals?.grand_total ||
data.details?.totals?.total ||
"";
const currency =
data.details?.totals?.currency_code || data.currency_code || "";
const totalLabel =
grand && currency ? `${formatMinor(grand, currency)} ${currency}` : grand || "";
await sendTransactionConfirmation({
to,
product: String(product),
totalLabel,
invoiceNumber: data.invoice_number || "",
transactionId: id,
subscriptionId: data.subscription_id || "",
});
break;
}
case "subscription.created": {
const to = await resolveBuyerEmail({
customerId: data.customer_id,
customData: data.custom_data,
});
if (!to) {
console.warn("skip mail: no buyer email for", id);
return;
}
const product =
data.items?.[0]?.price?.name ||
data.items?.[0]?.price?.description ||
"your plan";
await sendSubscriptionWelcome({
to,
product: String(product),
subscriptionId: id,
status: String(data.status ?? "active"),
});
break;
}
default:
break;
}
}
/** Paddle 金额字段通常是最低货币单位的字符串(例如美分)。 */
function formatMinor(amount: string, currency: string) {
const n = Number(amount);
if (!Number.isFinite(n)) return amount;
const zeroDecimal = new Set(["JPY", "KRW", "VND"]);
const major = zeroDecimal.has(currency.toUpperCase()) ? n : n / 100;
return major.toFixed(zeroDecimal.has(currency.toUpperCase()) ? 0 : 2);
}
async function sendTransactionConfirmation(opts: {
to: string;
product: string;
totalLabel: string;
invoiceNumber: string;
transactionId: string;
subscriptionId: string;
}) {
const templateId = process.env.ROVELA_TXN_TEMPLATE_ID;
const subject = opts.totalLabel
? `Payment confirmed — ${opts.totalLabel}`
: `Payment confirmed — ${opts.transactionId}`;
const html = `
<p>Thanks for your purchase of <b>${escapeHtml(opts.product)}</b>.</p>
<p><b>Transaction:</b> ${escapeHtml(opts.transactionId)}</p>
${
opts.invoiceNumber
? `<p><b>Invoice:</b> ${escapeHtml(opts.invoiceNumber)}</p>`
: ""
}
<p><b>Total:</b> ${escapeHtml(opts.totalLabel || "—")}</p>
<p>If you did not make this purchase, contact support.</p>
`;
const text = [
`Thanks for your purchase of ${opts.product}.`,
`Transaction: ${opts.transactionId}`,
opts.invoiceNumber ? `Invoice: ${opts.invoiceNumber}` : "",
`Total: ${opts.totalLabel || "—"}`,
"If you did not make this purchase, contact support.",
]
.filter(Boolean)
.join("\n");
const idempotencyKey =
`paddle-transaction.completed-${opts.transactionId}`.slice(0, 256);
try {
if (templateId) {
await sendViaRovela({
to: opts.to,
template: {
id: templateId,
variables: {
product: opts.product,
total: opts.totalLabel || "—",
invoice_number: opts.invoiceNumber || "",
transaction_id: opts.transactionId,
subscription_id: opts.subscriptionId || "",
},
},
idempotencyKey,
tags: {
paddle_event: "transaction.completed",
paddle_txn: opts.transactionId,
},
});
} else {
await sendViaRovela({
to: opts.to,
subject,
html,
text,
idempotencyKey,
tags: {
paddle_event: "transaction.completed",
paddle_txn: opts.transactionId,
},
});
}
} catch (err) {
if (err instanceof RovelaError) {
console.error("Rovela send failed", err.status, err.name, err.message);
}
throw err;
}
}
async function sendSubscriptionWelcome(opts: {
to: string;
product: string;
subscriptionId: string;
status: string;
}) {
const templateId = process.env.ROVELA_SUB_TEMPLATE_ID;
const subject = `Welcome to ${opts.product}`;
const html = `
<p>Your subscription to <b>${escapeHtml(opts.product)}</b> is ${escapeHtml(opts.status)}.</p>
<p><b>Subscription:</b> ${escapeHtml(opts.subscriptionId)}</p>
<p>We will email you about renewals and important account changes from this address.</p>
`;
const text = [
`Your subscription to ${opts.product} is ${opts.status}.`,
`Subscription: ${opts.subscriptionId}`,
"We will email you about renewals and important account changes from this address.",
].join("\n");
const idempotencyKey =
`paddle-subscription.created-${opts.subscriptionId}`.slice(0, 256);
if (templateId) {
await sendViaRovela({
to: opts.to,
template: {
id: templateId,
variables: {
product: opts.product,
status: opts.status,
subscription_id: opts.subscriptionId,
},
},
idempotencyKey,
tags: {
paddle_event: "subscription.created",
paddle_sub: opts.subscriptionId,
},
});
} else {
await sendViaRovela({
to: opts.to,
subject,
html,
text,
idempotencyKey,
tags: {
paddle_event: "subscription.created",
paddle_sub: opts.subscriptionId,
},
});
}
}
function escapeHtml(s: string) {
return s
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
}
挂载提示
import express from "express";
import { paddleWebhook } from "./routes/paddle-webhook";
const app = express();
app.use(paddleWebhook); // 先挂 raw body 路由
app.use(express.json()); // 其余 API 再用 JSON
若全局 JSON 解析器先跑,HMAC 校验会失败,因为签名覆盖的是 Paddle 发出的原始字节。
Next.js App Router 路由
// app/api/webhooks/paddle/route.ts
import { NextRequest, NextResponse } from "next/server";
import { verifyPaddleSignature } from "@/lib/paddle-verify";
import { resolveBuyerEmail } from "@/lib/paddle-email";
import { sendViaRovela } from "@/lib/rovela";
export const runtime = "nodejs";
export async function POST(req: NextRequest) {
const secret = process.env.PADDLE_WEBHOOK_SECRET;
if (!secret) {
return new NextResponse("secret not set", { status: 500 });
}
const rawBody = await req.text();
if (!verifyPaddleSignature(rawBody, req.headers.get("paddle-signature"), secret)) {
return new NextResponse("invalid signature", { status: 400 });
}
const event = JSON.parse(rawBody) as {
event_type?: string;
data?: {
id?: string;
status?: string;
customer_id?: string | null;
custom_data?: Record<string, unknown> | null;
invoice_number?: string | null;
currency_code?: string;
details?: { totals?: { grand_total?: string } };
items?: Array<{ price?: { name?: string | null } }>;
};
};
const data = event.data;
const id = data?.id;
if (event.event_type === "transaction.completed" && id) {
const to = await resolveBuyerEmail({
customerId: data?.customer_id,
customData: data?.custom_data,
});
if (to) {
const product = data?.items?.[0]?.price?.name || "your purchase";
const total = data?.details?.totals?.grand_total || "";
await sendViaRovela({
to,
subject: total
? `Payment confirmed — ${total} ${data?.currency_code || ""}`.trim()
: `Payment confirmed — ${id}`,
html: `<p>Thanks for buying <b>${product}</b>.</p><p>Transaction: ${id}</p>`,
text: `Thanks for buying ${product}.\nTransaction: ${id}`,
idempotencyKey: `paddle-transaction.completed-${id}`.slice(0, 256),
});
}
}
if (event.event_type === "subscription.created" && id) {
const to = await resolveBuyerEmail({
customerId: data?.customer_id,
customData: data?.custom_data,
});
if (to) {
const product = data?.items?.[0]?.price?.name || "your plan";
await sendViaRovela({
to,
subject: `Welcome to ${product}`,
html: `<p>Your subscription to <b>${product}</b> is ready.</p>`,
text: `Your subscription to ${product} is ready.`,
idempotencyKey: `paddle-subscription.created-${id}`.slice(0, 256),
});
}
}
return NextResponse.json({ received: true });
}
可选:控制台模板
先用 full_access 或控制台创建模板并发布,再在 webhook 里用 template.id + variables 发送(模板):
curl -X POST https://api.rovela.dev/templates \
-H "Authorization: Bearer re_your_full_access_key" \
-H "Content-Type: application/json" \
-d '{
"name": "Paddle transaction confirmation",
"subject": "Payment confirmed — {{total}}",
"html": "<p>Thanks for buying <b>{{product}}</b>.</p><p>Total: {{total}}</p><p>Transaction: {{transaction_id}}</p>",
"text": "Thanks for buying {{product}}. Total: {{total}}. Transaction: {{transaction_id}}."
}'
然后 POST /templates/:id/publish。生产 webhook 工作进程可继续只用 sending_access 密钥 —— 它们只调用 POST /emails。
记住:同一次发送不能把 template 与 html/text 混用,且 POST /emails/batch 会拒绝 template(发送邮件 API)。
Checkout custom_data → 你的应用用户
打开 Paddle.js Checkout 时传入结构化 customData(或在 API 创建的交易上设置 custom_data),例如 { "app_user_id": "usr_123", "email": "buyer@example.com" }。该对象会出现在交易上(通常也会带到相关订阅流量)。用它把购买关联到你自己的用户行,并在不想多打一轮 Customers API 时提供 to。
生产清单
- 在原始 body 上用
ts:body的 HMAC-SHA256 +timingSafeEqual(以及较紧的时间戳容差)校验Paddle-Signature。 - 尽快返回 2xx。 若 Rovela 或 Customers API 较慢,先入队
{ event_type, data.id, event_id }再异步发送;仍使用稳定的幂等键。 - 为
transaction.completed与subscription.created使用不同的 Idempotency-Key,让订阅销售可以发两封不同邮件而不冲突(发送邮件 API)。 - 工作进程优先使用
sending_access密钥;切勿暴露给客户端(鉴权)。 - 发送 html + text,或已发布的 template —— 不要空正文。
- 在 HTML / 模板变量前转义用户影响字段(模板)。
- 需要运维可见性时,用
GET /emails/:id或 Rovela webhook 确认投递 —— 发送200仅表示入队接受(快速开始)。
本文不覆盖
subscription.canceled、transaction.payment_failed、退款 / adjustment —— 同一套「校验 → 解析邮箱 → 发送」模式,换文案与键即可- 自行生成 PDF 附件 —— 见 带 PDF 附件的发票邮件
- Stripe、Shopify 或 Lemon Squeezy 的 webhook 签名 —— 见上文链接的专文
- Rovela 入站或投递 webhook —— 与 Paddle 的 endpoint secret 无关
小结
Paddle Billing 负责证明事件真实,以及交易已完成或订阅已创建。你的 webhook 解析买家邮箱,把 transaction.completed / subscription.created 映射到确认字段,并用稳定的 Idempotency-Key(paddle-transaction.completed-{txn_id} / paddle-subscription.created-{sub_id})调用 Rovela 的 POST /emails。结账与 MoR 收据留在 Paddle,品牌产品邮件交给 Rovela,密钥只放在服务器上。