← 返回博客

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

更新于 2026年11月4日
Polar.sh Webhook 确认邮件 + Rovela(2026):Standard Webhooks 与幂等键

Polar 负责开源/独立站结账 — Rovela 负责投递确认信

Polar 会在订单已支付或订阅创建时通知你的服务器——许多 indie 与开源 SaaS 用它做 GitHub 关联产品与周期性方案。Polar 侧自带的顾客回执是商务侧邮件,并不是从你自己的已验证产品域名、用你的模板、带着你的 Idempotency-Key 发出的自定义 HTTP 投递。当你的 webhook 处理器调用 POST https://api.rovela.dev/emails 时,Rovela 负责入队、DKIM 签名并投递这封事务邮件(发送邮件 API、快速开始、鉴权、模板)。

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

  1. 用 POLAR_WEBHOOK_SECRET 在原始请求体上校验 Standard Webhooks 风格头:webhook-id、webhook-timestamp、webhook-signature(Handle & monitor webhook deliveries)
  2. 处理 order.paid 与/或 subscription.created(来自事件的 type)
  3. 从 payload 嵌套的 customer 对象读取买家邮箱
  4. 通过 Rovela 发送购买或订阅确认信
  5. 设置稳定的 Idempotency-Key,避免 Polar 重试导致重复投递

Rovela 不会创建 Polar checkout、注册 webhook 端点,也不会校验 Polar 签名。 Polar 负责商务与事件投递;你的应用负责把 webhook 映射成顾客邮箱与模板变量;Rovela 只负责投递。

与这些文章区分:

Polar 做什么,Rovela 发什么

关注点负责方
结账、商品、订阅、权益、结算Polar
Webhook URL + 签名密钥 + webhook-* 头你的服务器 + Polar
选择哪些事件触发邮件你的应用
顾客邮箱、订单 id、商品名、金额Polar webhook payload(由你映射)
POST /emails、DKIM、队列、退信Rovela
Idempotency-Key = 事件类型 + 资源 id你的 webhook 处理器

Rovela 返回 200 表示已接受并入队,并不代表「已经进收件箱」(快速开始)。

前置条件

  1. 在 Rovela 控制台完成发信域名验证。未验证的 from 域名会以 400 拒绝(快速开始)。建议使用 orders.example.com 或 mail.example.com 一类子域。
  2. 形如 re_… 的 API Key。Webhook worker 优先使用 sending_access(鉴权)。
  3. 在 Polar 组织里配置指向你 HTTPS URL 的 webhook 端点,并保存仪表盘给出的 whsec_… 签名密钥(Setup Webhooks)。至少订阅 order.paid 与 subscription.created(Webhook Events)。
  4. 将 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, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;");
}

挂载提示

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,除非你有意改用数据库中已验证的地址。

生产检查清单

  1. 用 SDK(或 Standard Webhooks 库)在原始请求体上校验 Polar 签名;公网 URL 绝不能跳过(Handle & monitor webhook deliveries)。
  2. 尽快返回 2xx(Polar 示例使用 202)。若 Rovela 或数据库较慢,先入队 { type, resourceId, payload } 再异步发送;仍使用稳定幂等键。
  3. 收款确认优先监听 order.paid;把 order.created 当作「发票可能仍 pending」。
  4. 为 order.paid 与 subscription.created 使用不同的 Idempotency-Key,让订阅成交可以发两封不同邮件且不冲突(发送邮件 API)。
  5. Worker 优先使用 sending_access 密钥;切勿暴露给客户端(鉴权)。
  6. 发送 html + text,或已发布的 template——不要空正文。
  7. HTML / 模板变量写入前转义用户可控字段(模板)。
  8. 需要运维可见性时用 GET /emails/:id 或 Rovela webhook 确认投递——发送接口的 200 只代表入队(快速开始)。
  9. 若在 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,密钥只放服务端。