Polar.sh Webhook 确认邮件 + Rovela(2026):Standard Webhooks 与幂等键
在原始请求体上校验 Polar 的 webhook-id / webhook-timestamp / webhook-signature,处理 order.paid / subscription.created,并用 Idempotency-Key = polar-order.paid-{id} 或 polar-subscription.created-{id} 通过 Rovela POST /emails 发送品牌确认信。Polar 负责开源/独立站结账,Rovela 负责投递。与 Lemon Squeezy、Paddle、Stripe webhook 文区分。
Polar 负责开源/独立站结账 — Rovela 负责投递确认信
Polar 会在订单已支付或订阅创建时通知你的服务器——许多 indie 与开源 SaaS 用它做 GitHub 关联产品与周期性方案。Polar 侧自带的顾客回执是商务侧邮件,并不是从你自己的已验证产品域名、用你的模板、带着你的 Idempotency-Key 发出的自定义 HTTP 投递。当你的 webhook 处理器调用 POST https://api.rovela.dev/emails 时,Rovela 负责入队、DKIM 签名并投递这封事务邮件(发送邮件 API、快速开始、鉴权、模板)。
本指南会接好一条 Polar webhook,做到:
- 用
POLAR_WEBHOOK_SECRET在原始请求体上校验 Standard Webhooks 风格头:webhook-id、webhook-timestamp、webhook-signature(Handle & monitor webhook deliveries) - 处理
order.paid与/或subscription.created(来自事件的type) - 从 payload 嵌套的
customer对象读取买家邮箱 - 通过 Rovela 发送购买或订阅确认信
- 设置稳定的
Idempotency-Key,避免 Polar 重试导致重复投递
Rovela 不会创建 Polar checkout、注册 webhook 端点,也不会校验 Polar 签名。 Polar 负责商务与事件投递;你的应用负责把 webhook 映射成顾客邮箱与模板变量;Rovela 只负责投递。
与这些文章区分:
- Lemon Squeezy Webhook 确认邮件 — 原始体上的
X-Signature十六进制 HMAC +order_created/subscription_created,不是 Polar 的 Standard Webhooks 头 - Paddle Billing Webhook 邮件 —
Paddle-Signature(ts:rawBody)+transaction.completed,不是order.paid - Stripe Webhook 收据邮件 —
Stripe-Signature+ Checkout/Invoice/PaymentIntent 事件
Polar 做什么,Rovela 发什么
| 关注点 | 负责方 |
|---|---|
| 结账、商品、订阅、权益、结算 | Polar |
Webhook URL + 签名密钥 + webhook-* 头 | 你的服务器 + Polar |
| 选择哪些事件触发邮件 | 你的应用 |
| 顾客邮箱、订单 id、商品名、金额 | Polar webhook payload(由你映射) |
POST /emails、DKIM、队列、退信 | Rovela |
Idempotency-Key = 事件类型 + 资源 id | 你的 webhook 处理器 |
Rovela 返回 200 表示已接受并入队,并不代表「已经进收件箱」(快速开始)。
前置条件
- 在 Rovela 控制台完成发信域名验证。未验证的
from域名会以400拒绝(快速开始)。建议使用orders.example.com或mail.example.com一类子域。 - 形如
re_…的 API Key。Webhook worker 优先使用sending_access(鉴权)。 - 在 Polar 组织里配置指向你 HTTPS URL 的 webhook 端点,并保存仪表盘给出的
whsec_…签名密钥(Setup Webhooks)。至少订阅order.paid与subscription.created(Webhook Events)。 - 将
POLAR_WEBHOOK_SECRET、ROVELA_API_KEY、ROVELA_FROM只放在服务器上。不要放进浏览器包、营销站或移动 App。
环境变量
export POLAR_WEBHOOK_SECRET=whsec_your_polar_endpoint_secret
export ROVELA_API_KEY=re_your_sending_access_key
export ROVELA_FROM='Acme Orders <orders@mail.example.com>'
# 可选:控制台模板 UUID
export ROVELA_ORDER_TEMPLATE_ID=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee
export ROVELA_SUB_TEMPLATE_ID=ffffffff-1111-2222-3333-444444444444
轮换 Rovela 密钥时:先新建、部署,再吊销旧密钥(鉴权)。重建 Polar 端点时,在组织的 webhook 设置中轮换签名密钥。
Polar 文档说明:在 2026-09-08 00:00 UTC 及之后生成的密钥按 Standard Webhooks 处理密钥材料;更早的密钥使用 Polar HMAC。优先使用官方 SDK 校验(较新版本会同时尝试两种方案),不要手搓 HMAC(Handle & monitor webhook deliveries)。
为什么 Polar 场景必须用 Idempotency-Key
Polar 会对失败投递(非 2xx 或超时)做指数退避重试——最多约 10 次——并在连续失败 10 次后禁用端点(Handle & monitor webhook deliveries)。文档还建议端点在约 2 秒内响应,把重活放到异步队列;当前投递超时为 10 秒。
每次投递带有唯一的 webhook-id 头,适合做处理过程去重。但 Rovela 的发送幂等键仍应钉在你关心的商务资源上(每个已支付订单一封确认、每个订阅一封欢迎),而不要只依赖 webhook-id,这样同一业务事件的合法重投与你自己的「再发一次」路径才一致。
Rovela 的 Idempotency-Key 头(最长 256 字符)会让 24 小时内相同请求的重试返回原响应,而不是再发一封(发送邮件 API)。
推荐键:
| 键 | 适用场景 |
|---|---|
polar-order.paid-{data.id} | 每个已支付订单一封购买确认 |
polar-subscription.created-{data.id} | 每个订阅一封订阅欢迎 |
polar-{type}-{data.id} | 之后扩展其他事件的通用模式 |
重要重叠: 新订阅通常会发出 subscription.created,随后还有 order.paid(以及订单仍为 pending 时的 order.created)。建议:
order.paid→ 购买 / 收据确认(款项已收;status为 paid)subscription.created→ 订阅引导(方案/商品名等),使用不同模板与幂等键
或者只为产品邮件路径监听其中一个事件。发「感谢付款」邮件时,优先 order.paid 而不是 order.created——后者可能在付款仍 pending 时触发,续订场景尤其如此(Webhook Events)。
续订时 Polar 会按 subscription.cycled → order.created(pending)→ 收款后的 order.updated / order.paid 顺序触发。若你在每次 order.paid 发信,续订会收到续订收据(合理)。不要再从 subscription.cycled 发同一封「欢迎」信,除非文案刻意不同。
共用的 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 互斥 — 见模板文档
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 转义(模板)。
用官方 SDK 校验 Polar 签名
Polar 的 TypeScript / Python SDK 会用你的密钥校验 webhook-id / webhook-timestamp / webhook-signature 并解析事件。请使用 express.raw(或等价能力),让校验看到 Polar 签名的原始字节(Handle & monitor webhook deliveries)。
// src/routes/polar-webhook.ts
import express from "express";
import { webhooks } from "@polar-sh/sdk/2026-10";
import { sendViaRovela, RovelaError } from "../rovela";
type PolarCustomer = {
id?: string;
email?: string;
name?: string;
};
type PolarOrder = {
id?: string;
status?: string;
paid?: boolean;
billing_reason?: string;
invoice_number?: string;
currency?: string;
total_amount?: number;
customer?: PolarCustomer;
product?: { name?: string; id?: string };
subscription_id?: string | null;
};
type PolarSubscription = {
id?: string;
status?: string;
customer?: PolarCustomer;
product?: { name?: string; id?: string };
};
export const polarWebhook = express.Router();
polarWebhook.post(
"/webhooks/polar",
express.raw({ type: "application/json" }),
async (req, res) => {
const secret = process.env.POLAR_WEBHOOK_SECRET;
if (!secret) {
return res.status(500).send("webhook secret not configured");
}
try {
const event = await webhooks.validateEvent(
req.body,
{
"webhook-id": req.header("webhook-id") ?? "",
"webhook-timestamp": req.header("webhook-timestamp") ?? "",
"webhook-signature": req.header("webhook-signature") ?? "",
},
secret,
);
await handlePolarEvent(event as { type?: string; data?: unknown });
// Polar 文档用 202 确认
return res.status(202).send("");
} catch (error) {
if (error instanceof webhooks.PolarWebhookVerificationError) {
return res.status(403).send("");
}
if (error instanceof webhooks.PolarWebhookUnknownTypeError) {
// SDK 尚未认识的新事件类型 — 先 ack,避免 Polar 无限重试
return res.status(202).send("");
}
console.error("polar webhook handler failed", error);
// 非 2xx → Polar 会重试。发送较慢时建议先入队再 202。
return res.status(500).send("");
}
},
);
async function handlePolarEvent(event: { type?: string; data?: unknown }) {
switch (event.type) {
case "order.paid": {
const order = event.data as PolarOrder;
const id = order?.id;
const to = order?.customer?.email;
if (!id || !to) return;
const product = order.product?.name || "your purchase";
const total = formatMoney(order.total_amount, order.currency);
const invoice = String(order.invoice_number ?? "");
const reason = String(order.billing_reason ?? "");
await sendOrderConfirmation({
to,
name: order.customer?.name || "",
product: String(product),
total,
invoice,
billingReason: reason,
resourceId: id,
});
break;
}
case "subscription.created": {
const sub = event.data as PolarSubscription;
const id = sub?.id;
const to = sub?.customer?.email;
if (!id || !to) return;
await sendSubscriptionWelcome({
to,
name: sub.customer?.name || "",
product: String(sub.product?.name || "your plan"),
status: String(sub.status || "active"),
resourceId: id,
});
break;
}
default:
break;
}
}
function formatMoney(amount: number | undefined, currency: string | undefined) {
if (typeof amount !== "number") return "";
// Polar 金额为最小货币单位(如 USD 的 cents)
const major = amount / 100;
const cur = (currency || "usd").toUpperCase();
try {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: cur,
}).format(major);
} catch {
return `${major.toFixed(2)} ${cur}`;
}
}
async function sendOrderConfirmation(opts: {
to: string;
name: string;
product: string;
total: string;
invoice: string;
billingReason: string;
resourceId: string;
}) {
const templateId = process.env.ROVELA_ORDER_TEMPLATE_ID;
const isRenewal = opts.billingReason === "subscription_cycle";
const subject = isRenewal
? opts.total
? `Renewal receipt — ${opts.product} (${opts.total})`
: `Renewal receipt — ${opts.product}`
: opts.total
? `Order confirmed — ${opts.product} (${opts.total})`
: `Order confirmed — ${opts.product}`;
const html = `
<p>Hi ${escapeHtml(opts.name || "there")},</p>
<p>Thanks for your ${isRenewal ? "renewal" : "purchase"} of <b>${escapeHtml(opts.product)}</b>.</p>
<p><b>Total:</b> ${escapeHtml(opts.total || "—")}</p>
${
opts.invoice
? `<p><b>Invoice:</b> ${escapeHtml(opts.invoice)}</p>`
: ""
}
<p>If you did not make this payment, contact support.</p>
`;
const text = [
`Hi ${opts.name || "there"},`,
`Thanks for your ${isRenewal ? "renewal" : "purchase"} of ${opts.product}.`,
`Total: ${opts.total || "—"}`,
opts.invoice ? `Invoice: ${opts.invoice}` : "",
"If you did not make this payment, contact support.",
]
.filter(Boolean)
.join("\n");
const idempotencyKey = `polar-order.paid-${opts.resourceId}`.slice(0, 256);
try {
if (templateId) {
await sendViaRovela({
to: opts.to,
template: {
id: templateId,
variables: {
name: opts.name || "there",
product: opts.product,
total: opts.total || "—",
invoice: opts.invoice || "",
billing_reason: opts.billingReason || "",
},
},
idempotencyKey,
tags: { polar_event: "order.paid", polar_id: opts.resourceId },
});
} else {
await sendViaRovela({
to: opts.to,
subject,
html,
text,
idempotencyKey,
tags: { polar_event: "order.paid", polar_id: opts.resourceId },
});
}
} 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;
name: string;
product: string;
status: string;
resourceId: string;
}) {
const templateId = process.env.ROVELA_SUB_TEMPLATE_ID;
const subject = `Welcome to ${opts.product}`;
const html = `
<p>Hi ${escapeHtml(opts.name || "there")},</p>
<p>Your subscription to <b>${escapeHtml(opts.product)}</b> is ${escapeHtml(opts.status || "active")}.</p>
<p>We will email you about renewals and important account changes from this address.</p>
`;
const text = [
`Hi ${opts.name || "there"},`,
`Your subscription to ${opts.product} is ${opts.status || "active"}.`,
"We will email you about renewals and important account changes from this address.",
].join("\n");
const idempotencyKey = `polar-subscription.created-${opts.resourceId}`.slice(
0,
256,
);
if (templateId) {
await sendViaRovela({
to: opts.to,
template: {
id: templateId,
variables: {
name: opts.name || "there",
product: opts.product,
status: opts.status,
},
},
idempotencyKey,
tags: { polar_event: "subscription.created", polar_id: opts.resourceId },
});
} else {
await sendViaRovela({
to: opts.to,
subject,
html,
text,
idempotencyKey,
tags: { polar_event: "subscription.created", polar_id: opts.resourceId },
});
}
}
function escapeHtml(s: string) {
return s
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
}
挂载提示
import express from "express";
import { polarWebhook } from "./routes/polar-webhook";
const app = express();
app.use(polarWebhook); // 先挂 raw body 路由
app.use(express.json()); // 其余 API 再用 JSON
若全局 JSON 解析器先跑,签名校验会失败,因为签名覆盖的是 Polar 发送的原始字节。
Next.js App Router 路由
// app/api/webhooks/polar/route.ts
import { NextRequest, NextResponse } from "next/server";
import { webhooks } from "@polar-sh/sdk/2026-10";
import { sendViaRovela } from "@/lib/rovela";
export const runtime = "nodejs";
export async function POST(req: NextRequest) {
const secret = process.env.POLAR_WEBHOOK_SECRET;
if (!secret) {
return new NextResponse("secret not set", { status: 500 });
}
const rawBody = Buffer.from(await req.arrayBuffer());
let event: { type?: string; data?: Record<string, any> };
try {
event = (await webhooks.validateEvent(
rawBody,
{
"webhook-id": req.headers.get("webhook-id") ?? "",
"webhook-timestamp": req.headers.get("webhook-timestamp") ?? "",
"webhook-signature": req.headers.get("webhook-signature") ?? "",
},
secret,
)) as typeof event;
} catch (error) {
if (error instanceof webhooks.PolarWebhookVerificationError) {
return new NextResponse("", { status: 403 });
}
if (error instanceof webhooks.PolarWebhookUnknownTypeError) {
return new NextResponse("", { status: 202 });
}
throw error;
}
if (event.type === "order.paid") {
const order = event.data ?? {};
const id = order.id as string | undefined;
const to = order.customer?.email as string | undefined;
const product = (order.product?.name as string | undefined) || "your purchase";
if (id && to) {
const amount = order.total_amount as number | undefined;
const currency = String(order.currency || "usd").toUpperCase();
const total =
typeof amount === "number"
? new Intl.NumberFormat("en-US", {
style: "currency",
currency,
}).format(amount / 100)
: "";
await sendViaRovela({
to,
subject: total
? `Order confirmed — ${product} (${total})`
: `Order confirmed — ${product}`,
html: `<p>Thanks for your purchase of <b>${product}</b>.</p><p><b>Total:</b> ${total || "—"}</p>`,
text: `Thanks for your purchase of ${product}.\nTotal: ${total || "—"}`,
idempotencyKey: `polar-order.paid-${id}`.slice(0, 256),
});
}
}
if (event.type === "subscription.created") {
const sub = event.data ?? {};
const id = sub.id as string | undefined;
const to = sub.customer?.email as string | undefined;
const product = (sub.product?.name as string | undefined) || "your plan";
if (id && to) {
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: `polar-subscription.created-${id}`.slice(0, 256),
});
}
}
return new NextResponse("", { status: 202 });
}
可选:控制台模板
先用 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": "Polar order confirmation",
"subject": "Order confirmed — {{product}} ({{total}})",
"html": "<p>Hi {{name}},</p><p>Thanks for buying <b>{{product}}</b>.</p><p>Total: {{total}}</p>",
"text": "Hi {{name}}, thanks for buying {{product}}. Total: {{total}}."
}'
然后 POST /templates/:id/publish。生产环境的 webhook worker 可继续只用 sending_access 密钥——它们只调用 POST /emails。
注意:同一次发送不能混用 template 与 html/text,且 POST /emails/batch 会拒绝 template(发送邮件 API)。
metadata 与 external_id → 你的应用用户
Polar 顾客可带有你在结账时设置的 external_id,订单/订阅也可带 metadata。用它们把购买关联到你自己的用户行——发信时的 to 仍优先取 Polar payload 里的 customer.email,除非你有意改用数据库中已验证的地址。
生产检查清单
- 用 SDK(或 Standard Webhooks 库)在原始请求体上校验 Polar 签名;公网 URL 绝不能跳过(Handle & monitor webhook deliveries)。
- 尽快返回 2xx(Polar 示例使用 202)。若 Rovela 或数据库较慢,先入队
{ type, resourceId, payload }再异步发送;仍使用稳定幂等键。 - 收款确认优先监听
order.paid;把order.created当作「发票可能仍 pending」。 - 为
order.paid与subscription.created使用不同的 Idempotency-Key,让订阅成交可以发两封不同邮件且不冲突(发送邮件 API)。 - Worker 优先使用
sending_access密钥;切勿暴露给客户端(鉴权)。 - 发送 html + text,或已发布的 template——不要空正文。
- HTML / 模板变量写入前转义用户可控字段(模板)。
- 需要运维可见性时用
GET /emails/:id或 Rovela webhook 确认投递——发送接口的200只代表入队(快速开始)。 - 若在 Cloudflare 终止 TLS,注意 Bot Fight Mode / WAF——Polar 文档提到机器人挑战会导致
403,连续失败后会禁用端点。
本文未覆盖
order.refunded、subscription.canceled/subscription.revoked、subscription.past_due— 同一套「校验 → 映射 → 发送」模式,换文案与幂等键即可- Polar benefit grant / seat claim — 履约副作用,不是 Rovela 的职责
- 自建 PDF 附件 — 见 带 PDF 附件的发票邮件
- Lemon Squeezy、Paddle 或 Stripe 的 webhook 签名 — 见上文链接的专文
- Rovela 入站或投递 webhook — 与 Polar 签名密钥无关
小结
Polar 负责证明事件真实,以及订单已支付或订阅已创建。你的 webhook 把 order.paid / subscription.created 映射成收件人与确认字段。Rovela 的 POST /emails 配上稳定的 Idempotency-Key(polar-order.paid-{id} / polar-subscription.created-{id}),即使 Polar 重试也只会按逻辑事件投递一封。结账留在 Polar,投递交给 Rovela,密钥只放服务端。