Lemon Squeezy Webhook 确认邮件 + Rovela(2026):X-Signature 与幂等键
在原始请求体上校验 Lemon Squeezy X-Signature,处理 order_created / subscription_created,并用 Idempotency-Key = ls-order_created-{id} 或 ls-subscription_created-{id} 通过 Rovela POST /emails 发送品牌确认信。Lemon Squeezy 负责结账,Rovela 负责投递。与 Stripe、Shopify webhook 文区分。
Lemon Squeezy 负责结账 — Rovela 负责投递确认信
Lemon Squeezy 会在顾客购买商品或开通订阅时通知你的服务器。店铺侧自带的顾客邮件,并不是从你自己的已验证产品域名、用你的模板、带着你的 Idempotency-Key 发出的自定义 HTTP 投递。当你的 webhook 处理器调用 POST https://api.rovela.dev/emails 时,Rovela 负责入队、DKIM 签名并投递这封事务邮件(发送邮件 API、快速开始、鉴权、模板)。
本指南会接好一条 Lemon Squeezy webhook,做到:
- 用 webhook 签名密钥对原始请求体做 HMAC-SHA256,校验
X-Signature(Signing requests) - 处理
order_created与/或subscription_created(来自X-Event-Name/meta.event_name) - 通过 Rovela 发送购买确认或订阅欢迎邮件
- 设置稳定的
Idempotency-Key,避免 Lemon Squeezy 重试导致重复发信
Rovela 不会创建 Lemon Squeezy 结账、注册 webhook,也不会替你校验 X-Signature。 商务与事件投递归 Lemon Squeezy;把 webhook 映射到收件人与模板变量归你的应用;Rovela 只负责投递。
与下列文章区分:
- Stripe Webhook 收据邮件 —
Stripe-Signature+ Checkout/Invoice/PaymentIntent,不是 Lemon Squeezy 的 JSON:API webhook - Shopify 订单 Webhook 邮件 —
X-Shopify-Hmac-SHA256+orders/create,不是 Lemon Squeezy 的order_created/subscription_created - 电商订单邮件 — 通用 OMS/购物车生命周期,不含 Lemon Squeezy 签名校验
Lemon Squeezy 做什么,Rovela 发什么
| 职责 | 归属 |
|---|---|
| 结账、商品、订阅、税费、结算 | Lemon Squeezy |
Webhook URL + 签名密钥 + X-Signature | 你的服务器 + Lemon Squeezy |
| 选择哪些事件触发邮件 | 你的应用 |
| 顾客邮箱、订单号、商品名、金额 | Lemon Squeezy webhook 载荷(你来映射) |
POST /emails、DKIM、队列、退信 | Rovela |
Idempotency-Key = 事件 + 资源 id | 你的 webhook 处理器 |
Rovela 返回 200 表示已接受并入队,并不等于「已经进收件箱」(快速开始)。
前置条件
- 在 Rovela 控制台完成发信域名验证。未验证的
from域名会以400拒绝(快速开始)。建议使用orders.example.com或mail.example.com一类域名。 - API Key 形如
re_…。Webhook worker 优先使用sending_access(鉴权)。 - Lemon Squeezy 店铺 webhook 指向你的 HTTPS 端点,并保存创建时设置的签名密钥(通常 6–40 个字符)(Webhook requests)。
LEMONSQUEEZY_WEBHOOK_SECRET、ROVELA_API_KEY、ROVELA_FROM只放在服务端,不要进浏览器包、营销站或移动端。
环境变量
export LEMONSQUEEZY_WEBHOOK_SECRET=your_webhook_signing_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 密钥:先创建新密钥并部署,再吊销旧密钥(鉴权)。重建 Lemon Squeezy webhook 端点时,在 Settings → Webhooks 轮换签名密钥。
为什么 Lemon Squeezy 必须配 Idempotency-Key
Lemon Squeezy 需要 HTTP 200 表示投递成功。其他状态码会按指数退避最多再重试三次(Webhook requests)。它没有类似 Shopify webhook id 的专用投递 ID 头,你必须从载荷推导稳定键。
Rovela 的 Idempotency-Key 头(最长 256 字符)会让 24 小时内相同发送的重试返回原始响应,而不是再发一封(发送邮件 API)。
推荐键:
| 键 | 使用场景 |
|---|---|
ls-order_created-{data.id} | 每个订单资源一封购买确认(截断到 256) |
ls-subscription_created-{data.id} | 每个订阅资源一封订阅欢迎 |
ls-{event_name}-{data.id} | 后续扩展其他事件的通用写法 |
重叠注意: 订阅类购买通常会同时发出 order_created 与 subscription_created(还常有 subscription_payment_success)。若两个处理器都发同一句「感谢购买」,顾客会收到重复邮件。建议:
order_created→ 购买 / 收据确认subscription_created→ 订阅专属引导(门户链接、套餐名),使用不同模板与幂等键
或者产品邮件路径只监听其中一个事件。
共用 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 转义(模板)。
在原始请求体上校验 X-Signature
Lemon Squeezy 的 X-Signature 是用签名密钥对请求体精确字节做 HMAC-SHA256 得到的小写十六进制摘要(Signing requests)。用常量时间比较。哈希前不要重新序列化 JSON。
// src/lemonsqueezy-verify.ts
import crypto from "node:crypto";
export function verifyLemonSqueezySignature(
rawBody: string | Buffer,
signatureHeader: string | null | undefined,
secret: string,
): boolean {
if (!signatureHeader) return false;
const digest = crypto
.createHmac("sha256", secret)
.update(rawBody)
.digest("hex");
const expected = Buffer.from(digest, "utf8");
const received = Buffer.from(signatureHeader, "utf8");
if (expected.length !== received.length) return false;
return crypto.timingSafeEqual(expected, received);
}
Express:先校验,再发送
在 webhook 路由上使用 express.raw,让签名校验看到与 Lemon Squeezy 签名时相同的字节。
// src/routes/lemonsqueezy-webhook.ts
import express from "express";
import { verifyLemonSqueezySignature } from "../lemonsqueezy-verify";
import { sendViaRovela, RovelaError } from "../rovela";
type LsWebhook = {
meta?: {
event_name?: string;
custom_data?: Record<string, unknown>;
};
data?: {
type?: string;
id?: string;
attributes?: Record<string, unknown>;
};
};
export const lemonSqueezyWebhook = express.Router();
lemonSqueezyWebhook.post(
"/webhooks/lemonsqueezy",
express.raw({ type: "application/json" }),
async (req, res) => {
const secret = process.env.LEMONSQUEEZY_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["x-signature"];
const sig = Array.isArray(signature) ? signature[0] : signature;
if (!verifyLemonSqueezySignature(rawBody, sig, secret)) {
return res.status(400).send("invalid signature");
}
let payload: LsWebhook;
try {
payload = JSON.parse(rawBody.toString("utf8")) as LsWebhook;
} catch {
return res.status(400).send("invalid json");
}
const eventName =
(typeof req.headers["x-event-name"] === "string"
? req.headers["x-event-name"]
: undefined) || payload.meta?.event_name;
try {
await handleLemonEvent(eventName, payload);
return res.status(200).json({ received: true });
} catch (err) {
console.error("lemonsqueezy webhook handler failed", err);
// 非 2xx → Lemon Squeezy 会重试。发送较慢时优先入队后回 200。
return res.status(500).json({ error: "handler failed" });
}
},
);
async function handleLemonEvent(eventName: string | undefined, payload: LsWebhook) {
const id = payload.data?.id;
const attrs = payload.data?.attributes ?? {};
if (!id) return;
switch (eventName) {
case "order_created": {
const status = String(attrs.status ?? "");
// 只对已支付订单发信;跳过 pending/failed/fraudulent
if (status && status !== "paid") return;
const to = String(attrs.user_email ?? "");
if (!to) return;
const orderNumber = String(attrs.order_number ?? id);
const total = String(attrs.total_formatted ?? "");
const name = String(attrs.user_name ?? "");
const firstItem = attrs.first_order_item as
| { product_name?: string; variant_name?: string }
| undefined;
const product =
firstItem?.product_name ||
firstItem?.variant_name ||
"your purchase";
const receiptUrl =
attrs.urls && typeof attrs.urls === "object"
? String((attrs.urls as { receipt?: string }).receipt ?? "")
: "";
await sendOrderConfirmation({
to,
name,
orderNumber,
total,
product: String(product),
receiptUrl,
resourceId: id,
});
break;
}
case "subscription_created": {
const to = String(attrs.user_email ?? "");
if (!to) return;
const product = String(attrs.product_name ?? "your plan");
const variant = String(attrs.variant_name ?? "");
const status = String(attrs.status_formatted ?? attrs.status ?? "");
const portal =
attrs.urls && typeof attrs.urls === "object"
? String((attrs.urls as { customer_portal?: string }).customer_portal ?? "")
: "";
await sendSubscriptionWelcome({
to,
name: String(attrs.user_name ?? ""),
product,
variant,
status,
portalUrl: portal,
resourceId: id,
});
break;
}
default:
break;
}
}
async function sendOrderConfirmation(opts: {
to: string;
name: string;
orderNumber: string;
total: string;
product: string;
receiptUrl: string;
resourceId: string;
}) {
const templateId = process.env.ROVELA_ORDER_TEMPLATE_ID;
const subject = opts.total
? `Order #${opts.orderNumber} confirmed — ${opts.total}`
: `Order #${opts.orderNumber} confirmed`;
const html = `
<p>Hi ${escapeHtml(opts.name || "there")},</p>
<p>Thanks for your purchase of <b>${escapeHtml(opts.product)}</b>.</p>
<p><b>Order:</b> #${escapeHtml(opts.orderNumber)}</p>
<p><b>Total:</b> ${escapeHtml(opts.total || "—")}</p>
${
opts.receiptUrl
? `<p><a href="${escapeHtml(opts.receiptUrl)}">View receipt</a></p>`
: ""
}
<p>If you did not make this purchase, contact support.</p>
`;
const text = [
`Hi ${opts.name || "there"},`,
`Thanks for your purchase of ${opts.product}.`,
`Order: #${opts.orderNumber}`,
`Total: ${opts.total || "—"}`,
opts.receiptUrl ? `Receipt: ${opts.receiptUrl}` : "",
"If you did not make this purchase, contact support.",
]
.filter(Boolean)
.join("\n");
const idempotencyKey = `ls-order_created-${opts.resourceId}`.slice(0, 256);
try {
if (templateId) {
await sendViaRovela({
to: opts.to,
template: {
id: templateId,
variables: {
name: opts.name || "there",
order_number: opts.orderNumber,
total: opts.total || "—",
product: opts.product,
receipt_url: opts.receiptUrl || "",
},
},
idempotencyKey,
tags: { ls_event: "order_created", ls_id: opts.resourceId },
});
} else {
await sendViaRovela({
to: opts.to,
subject,
html,
text,
idempotencyKey,
tags: { ls_event: "order_created", ls_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;
variant: string;
status: string;
portalUrl: string;
resourceId: string;
}) {
const templateId = process.env.ROVELA_SUB_TEMPLATE_ID;
const plan = opts.variant ? `${opts.product} (${opts.variant})` : opts.product;
const subject = `Welcome to ${plan}`;
const html = `
<p>Hi ${escapeHtml(opts.name || "there")},</p>
<p>Your subscription to <b>${escapeHtml(plan)}</b> is ${escapeHtml(opts.status || "active")}.</p>
${
opts.portalUrl
? `<p><a href="${escapeHtml(opts.portalUrl)}">Manage billing</a></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 ${plan} is ${opts.status || "active"}.`,
opts.portalUrl ? `Manage billing: ${opts.portalUrl}` : "",
"We will email you about renewals and important account changes from this address.",
]
.filter(Boolean)
.join("\n");
const idempotencyKey = `ls-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,
variant: opts.variant,
status: opts.status,
portal_url: opts.portalUrl || "",
},
},
idempotencyKey,
tags: { ls_event: "subscription_created", ls_id: opts.resourceId },
});
} else {
await sendViaRovela({
to: opts.to,
subject,
html,
text,
idempotencyKey,
tags: { ls_event: "subscription_created", ls_id: opts.resourceId },
});
}
}
function escapeHtml(s: string) {
return s
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
}
挂载提示
import express from "express";
import { lemonSqueezyWebhook } from "./routes/lemonsqueezy-webhook";
const app = express();
app.use(lemonSqueezyWebhook); // 先挂 raw body 路由
app.use(express.json()); // 其余 API 再用 JSON
若全局 JSON 解析器先跑,HMAC 校验会失败,因为签名覆盖的是 Lemon Squeezy 发出的原始字节。
Next.js App Router 路由
// app/api/webhooks/lemonsqueezy/route.ts
import { NextRequest, NextResponse } from "next/server";
import crypto from "node:crypto";
import { sendViaRovela } from "@/lib/rovela";
export const runtime = "nodejs";
function verify(rawBody: string, signature: string | null, secret: string) {
if (!signature) return false;
const digest = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
const a = Buffer.from(digest, "utf8");
const b = Buffer.from(signature, "utf8");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
export async function POST(req: NextRequest) {
const secret = process.env.LEMONSQUEEZY_WEBHOOK_SECRET;
if (!secret) {
return new NextResponse("secret not set", { status: 500 });
}
const rawBody = await req.text();
if (!verify(rawBody, req.headers.get("x-signature"), secret)) {
return new NextResponse("invalid signature", { status: 400 });
}
const payload = JSON.parse(rawBody) as {
meta?: { event_name?: string };
data?: { id?: string; attributes?: Record<string, unknown> };
};
const eventName =
req.headers.get("x-event-name") || payload.meta?.event_name;
const id = payload.data?.id;
const attrs = payload.data?.attributes ?? {};
if (eventName === "order_created" && id && attrs.user_email) {
if (!attrs.status || attrs.status === "paid") {
const orderNumber = String(attrs.order_number ?? id);
const total = String(attrs.total_formatted ?? "");
await sendViaRovela({
to: String(attrs.user_email),
subject: total
? `Order #${orderNumber} confirmed — ${total}`
: `Order #${orderNumber} confirmed`,
html: `<p>Thanks for your purchase.</p><p><b>Order:</b> #${orderNumber}</p><p><b>Total:</b> ${total || "—"}</p>`,
text: `Thanks for your purchase.\nOrder: #${orderNumber}\nTotal: ${total || "—"}`,
idempotencyKey: `ls-order_created-${id}`.slice(0, 256),
});
}
}
if (eventName === "subscription_created" && id && attrs.user_email) {
const product = String(attrs.product_name ?? "your plan");
await sendViaRovela({
to: String(attrs.user_email),
subject: `Welcome to ${product}`,
html: `<p>Your subscription to <b>${product}</b> is ready.</p>`,
text: `Your subscription to ${product} is ready.`,
idempotencyKey: `ls-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": "Lemon Squeezy order confirmation",
"subject": "Order #{{order_number}} confirmed — {{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}}. Order #{{order_number}}."
}'
然后 POST /templates/:id/publish。生产 webhook worker 可继续只用 sending_access 密钥——它们只需调用 POST /emails。
注意:同一次发送不能把 template 与 html/text 混用;POST /emails/batch 会拒绝 template(发送邮件 API)。
Checkout custom_data → 你的应用用户
若你在 Lemon Squeezy checkout 传入 custom_data(例如 { "app_user_id": "usr_123" }),它会出现在 Order / Subscription webhook 的 meta.custom_data(Webhook requests)。发信前或发信时用它关联你自己的用户行——除非你有意用数据库中已验证的地址覆盖,否则 to 仍取 Lemon Squeezy 属性里的 user_email。
生产检查清单
- 在原始请求体上用 HMAC-SHA256 +
timingSafeEqual校验X-Signature。 - 尽快返回 2xx。若 Rovela 或数据库较慢,先入队
{ eventName, resourceId, payload }再异步发送;仍使用稳定幂等键。 - 为
order_created与subscription_created使用不同的 Idempotency-Key,让订阅购买可以发出两封不同邮件而不互相冲突(发送邮件 API)。 - Worker 优先使用
sending_access密钥;切勿暴露给客户端(鉴权)。 - 发送 html + text,或已发布的 template——不要空正文。
- 写入 HTML / 模板变量前转义用户影响字段(模板)。
- 需要运维可见性时,用
GET /emails/:id或 Rovela webhook 确认投递——发送接口的200只表示入队成功(快速开始)。
本文未覆盖
order_refunded、subscription_cancelled、subscription_payment_failed— 同一套「校验 → 映射 → 发送」模式,换文案与幂等键即可- 自行生成 PDF 附件 — 见 带 PDF 附件的发票邮件
- Stripe / Shopify 的 webhook 签名 — 见上文相关博文
- Rovela 入站或投递 webhook — 与 Lemon Squeezy 签名密钥无关
小结
Lemon Squeezy 证明事件真实且购买/订阅已创建。你的 webhook 把 order_created / subscription_created 映射到收件人与确认字段。Rovela 的 POST /emails 配合稳定的 Idempotency-Key(ls-order_created-{id} / ls-subscription_created-{id}),即使 Lemon Squeezy 重试,每个逻辑事件也只发出一封。结账留在 Lemon Squeezy,投递交给 Rovela,密钥只放服务端。