← 返回博客

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 文区分。

更新于 2026年11月4日
Paddle Billing Webhook 邮件 + Rovela(2026):Paddle-Signature 与幂等键

Paddle Billing 负责结账 — Rovela 负责投递确认信

Paddle Billing 会在交易完成处理或订阅创建时通知你的服务器。Paddle 作为商户记录方(MoR)发出的顾客收据,并不是从你自己的已验证产品域名、用你的模板、带着你的 Idempotency-Key 发出的自定义 HTTP 投递。当你的 webhook 处理器调用 POST https://api.rovela.dev/emails 时,Rovela 负责入队、DKIM 签名并投递这封品牌事务邮件(发送邮件 API、快速开始、鉴权、模板)。

本指南会接好一条 Paddle Billing webhook,做到:

  1. 校验 Paddle-Signature 头(用通知目的地密钥对 ts:rawBody 做 HMAC-SHA256)(签名校验)
  2. 处理 transaction.completed 和/或 subscription.created
  3. 解析买家邮箱(Paddle 交易负载提供的是 customer_id,不是邮箱本身)
  4. 通过 Rovela 发送购买或订阅确认信
  5. 设置稳定的 Idempotency-Key,避免 Paddle 重试导致重复投递

Rovela 不会创建 Paddle 结账、注册通知目的地,也不会校验 Paddle-Signature。 商务、税务与事件投递归 Paddle;把 webhook 映射到顾客邮箱与模板变量归你的应用;Rovela 只负责投递。

与下列文章区分:

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 表示已接受并入队,不代表「已经进收件箱」(快速开始)。

前置条件

  1. 在 Rovela 控制台有一个已验证发信域名。未验证的 from 域名会以 400 拒绝(快速开始)。推荐使用 orders.example.com 或 mail.example.com 这类子域。
  2. 形如 re_… 的 API 密钥。webhook 工作进程优先使用 sending_access(鉴权)。
  3. 一个类型为 url 的 Paddle 通知目的地,至少订阅 transaction.completed 与 subscription.created,并保存其 endpoint secret key(控制台或 GET /notification-settings/{id})(Webhook 工作原理、签名校验)。
  4. 准备好买家邮箱策略:在结账时写入 customData、用 customer_id 调 Paddle Customers API,和/或本地存储 customer.created / customer.updated。仅凭交易 webhook 拿不到邮箱地址。
  5. 把 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。按此顺序解析:

  1. 你在结账 / 交易上设置的 custom_data(例如 { "app_user_id": "usr_123", "email": "buyer@example.com" })
  2. 你自己的数据库,用 custom_data.app_user_id 或此前由 customer.created / customer.updated 存下的 customer_id → email 映射
  3. 使用服务端 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, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;");
}

挂载提示

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。

生产清单

  1. 在原始 body 上用 ts:body 的 HMAC-SHA256 + timingSafeEqual(以及较紧的时间戳容差)校验 Paddle-Signature。
  2. 尽快返回 2xx。 若 Rovela 或 Customers API 较慢,先入队 { event_type, data.id, event_id } 再异步发送;仍使用稳定的幂等键。
  3. 为 transaction.completed 与 subscription.created 使用不同的 Idempotency-Key,让订阅销售可以发两封不同邮件而不冲突(发送邮件 API)。
  4. 工作进程优先使用 sending_access 密钥;切勿暴露给客户端(鉴权)。
  5. 发送 html + text,或已发布的 template —— 不要空正文。
  6. 在 HTML / 模板变量前转义用户影响字段(模板)。
  7. 需要运维可见性时,用 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,密钥只放在服务器上。