Gumroad Ping Webhook 邮件 + Rovela(2026):X-Gumroad-Signature、Sales API 与幂等键
在原始表单请求体上可选校验 X-Gumroad-Signature,用 GET /v2/sales/{sale_id} 回读销售,并用 Idempotency-Key = gumroad-sale-{sale_id} 通过 Rovela POST /emails 发送品牌感谢/交付信。Gumroad 负责结账,Rovela 负责投递。与 Polar、Lemon Squeezy、Paddle、Stripe webhook 文区分。
Gumroad 负责结账 — Rovela 负责投递感谢信
Gumroad Ping 会在有人购买(或之后退款)你的商品时,向你在账户设置里配置的 URL 发送实时 HTTP POST。载荷是 application/x-www-form-urlencoded,不是 JSON。Gumroad 自带的买家收据是店铺侧邮件;它并不是从你自己的已验证产品域名、用你的模板、带着你的 Idempotency-Key 发出的自定义 HTTP 投递。当你的 Ping 处理器调用 POST https://api.rovela.dev/emails 时,Rovela 负责入队、DKIM 签名并投递这封事务邮件(发送邮件 API、快速开始、鉴权、模板)。
本指南会接好一条 Gumroad Ping 端点,做到:
- 在开启签名时,可选校验
X-Gumroad-Signature(t=…,v1=…,对t.+ 原始请求体做 HMAC-SHA256)(Verifying a ping) - 把 Ping 当作触发器:取出
sale_id,再用GET https://api.gumroad.com/v2/sales/{sale_id}(view_salestoken)确认这笔销售(Gumroad API) - 在
resource_name=sale(且未退款)时,通过 Rovela 发送交付 / 感谢信 - 设置稳定的
Idempotency-Key,避免至少一次投递的 Ping 重试导致重复发信
Rovela 不会创建 Gumroad 商品、配置 Ping URL、校验 X-Gumroad-Signature,也不会代你调用 Gumroad Sales API。 Gumroad 负责商业与 Ping 投递;你的应用负责校验 → 拉取销售 → 映射字段 → 模板变量;Rovela 只负责投递。
与下列文章区分:
- Polar.sh Webhook 确认邮件 — Polar 签名与订单/订阅事件,不是 Gumroad 表单编码 Ping
- Lemon Squeezy Webhook 确认邮件 —
X-Signature+ JSON:APIorder_created/subscription_created - Paddle Billing Webhook 邮件 —
Paddle-Signature+ Billing 通知 - Stripe Webhook 收据邮件 —
Stripe-Signature+ Checkout/Invoice/PaymentIntent 事件
Gumroad 做什么 vs Rovela 发什么
| 关注点 | 负责方 |
|---|---|
| 商品、结账、打款、内置收据 | Gumroad |
Ping URL + 可选签名密钥 + X-Gumroad-Signature | 你的服务器 + Gumroad |
GET /v2/sales/{sale_id} 真实性核对 | 你的服务器 + Gumroad OAuth(view_sales) |
| 何时发信(sale / refund / test) | 你的应用 |
| 买家邮箱、商品名、价格、license_key | Ping 字段与/或 Sales API 的 sale 对象(由你映射) |
POST /emails、DKIM、队列、退信 | Rovela |
Idempotency-Key = gumroad-{resource_name}-{sale_id} | 你的 Ping 处理器 |
Rovela 返回 200 表示已接受并入队,并不表示「已经进收件箱」(快速开始)。
前置条件
- 在 Rovela 控制台配置已验证发信域名。未验证的
from域名会以400拒绝(快速开始)。建议使用orders.example.com或mail.example.com。 - API Key 形如
re_…。Ping worker 优先使用sending_access(鉴权)。 - 将 Gumroad Ping URL 指向你的 HTTPS 端点(Gumroad Ping)。在账户设置中打开签名并保存可轮换密钥——在此之前,端点 URL 是请求上唯一的凭据。
- 准备带
view_sales的 Gumroad OAuth access token,以便在 Ping 之后回读销售(Gumroad API)。 - 将
GUMROAD_PING_SECRET、GUMROAD_ACCESS_TOKEN、ROVELA_API_KEY、ROVELA_FROM只放在服务器上。不要放进浏览器包、营销站或移动应用。
环境变量
export GUMROAD_PING_SECRET=your_ping_signing_secret
export GUMROAD_ACCESS_TOKEN=your_view_sales_token
export GUMROAD_EXPECTED_SELLER_ID=your_seller_id # 可选硬校验
export ROVELA_API_KEY=re_your_sending_access_key
export ROVELA_FROM='Acme <orders@mail.example.com>'
# 可选:控制台模板 UUID
export ROVELA_SALE_TEMPLATE_ID=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee
轮换 Rovela Key:先创建新 Key、部署,再吊销旧 Key(鉴权)。轮换 Gumroad Ping 密钥会立即生效——轮换后立刻更新 worker 侧副本(Verifying a ping)。
为什么 Gumroad 上真实性要两步
Gumroad 对 Ping 投递有明确约定(Gumroad Ping):
- 投递是至少一次;同一条 Ping 可能到达多次
- 不保证顺序(退款 Ping 可能早于对应的销售 Ping)
- 按
sale_id+resource_name去重,不要只按sale_id——退款会复用同一个 sale id - 端点需在 5 秒内响应;工作慢时先确认再异步处理
- 重试节奏:1 分钟 / 3 分钟 / 10 分钟 / 1 小时,且仅对 499 / 500 / 502 / 503 / 504 重试
- 签名默认关闭;开启后
X-Gumroad-Signature: t=…,v1=…是对t.+ 原始字节的 HMAC-SHA256 - 即使有签名,也要把 Ping 当触发器:取
sale_id,用 API 回读销售,并定期对账
因此安全发信路径是:(若开启)校验签名 → 拉取销售 → 再 POST /emails。
为什么 Gumroad 场景需要 Idempotency-Key
Rovela 的 Idempotency-Key 请求头(最多 256 字符)会让 24 小时内对同一次发送的重试返回原始响应,而不是再发一封(发送邮件 API)。
推荐键:
| Key | 使用场景 |
|---|---|
gumroad-sale-{sale_id} | 每笔购买一封感谢 / 交付邮件 |
gumroad-refund-{sale_id} | 每笔销售一封退款通知(若你后续加上) |
gumroad-{resource_name}-{sale_id} | 通用模式 |
不要用签名当幂等键:重试的 Ping 会用新的 t 重新签名,因此 v1 会变而载荷不变(Verifying a ping)。
当 Ping 的 test=true(账户「Send test ping」或购买自己的商品)时,除非你有意做沙箱发信,否则跳过发信。
共用的 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-Gumroad-Signature
开启签名后,从 X-Gumroad-Signature 解析 t 与 v1,拒绝超过 5 分钟的时钟偏差,并用恒定时间比较 HMAC-SHA256 十六进制摘要。务必对原始请求体字节签名——不要重新编码表单字段(Verifying a ping)。
// src/gumroad-verify.ts
import crypto from "node:crypto";
const MAX_SKEW_SECONDS = 300;
export function verifyGumroadSignature(
rawBody: string | Buffer,
signatureHeader: string | null | undefined,
secret: string,
): boolean {
if (!signatureHeader) return false;
const parts = Object.fromEntries(
signatureHeader.split(",").map((p) => {
const i = p.indexOf("=");
return i === -1 ? [p, ""] : [p.slice(0, i).trim(), p.slice(i + 1).trim()];
}),
) as { t?: string; v1?: string };
const t = parts.t;
const v1 = parts.v1;
if (!t || !v1 || !/^\d+$/.test(t)) return false;
if (Math.abs(Date.now() / 1000 - Number(t)) > MAX_SKEW_SECONDS) return false;
const bodyBuf = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody);
const expected = Buffer.from(
crypto.createHmac("sha256", secret).update(t + ".").update(bodyBuf).digest("hex"),
);
const received = Buffer.from(v1);
if (expected.length !== received.length) return false;
return crypto.timingSafeEqual(expected, received);
}
若账户设置里签名仍关闭,仅在你完成开启前可暂时跳过请求头校验——但发信前仍须用私有 token 回读销售。
拉取销售(Ping 不是付款证明)
// src/gumroad-sales.ts
export type GumroadSale = {
id: string;
email?: string;
purchase_email?: string;
full_name?: string | null;
product_id?: string;
product_name?: string;
product_permalink?: string;
price?: number;
formatted_display_price?: string;
formatted_total_price?: string;
refunded?: boolean;
seller_id?: string;
license_key?: string | null;
quantity?: number;
order_id?: number;
};
export async function fetchGumroadSale(saleId: string): Promise<GumroadSale> {
const token = process.env.GUMROAD_ACCESS_TOKEN;
if (!token) throw new Error("GUMROAD_ACCESS_TOKEN must be set");
const url = new URL(`https://api.gumroad.com/v2/sales/${encodeURIComponent(saleId)}`);
url.searchParams.set("access_token", token);
const res = await fetch(url.toString(), { method: "GET" });
const json = (await res.json()) as {
success?: boolean;
sale?: GumroadSale;
message?: string;
};
if (!res.ok || !json.success || !json.sale) {
throw new Error(json.message || `Gumroad sale ${saleId} not found`);
}
const expectedSeller = process.env.GUMROAD_EXPECTED_SELLER_ID;
if (expectedSeller && json.sale.seller_id && json.sale.seller_id !== expectedSeller) {
throw new Error("seller_id mismatch");
}
return json.sale;
}
优先使用该响应中的 sale.email / sale.purchase_email 与商品字段,而不要单独信任 Ping 表单值。
Express:原始请求体 → 校验 → 拉取 → 发送
在 Ping 路由上使用 raw body 解析器,让签名校验看到与 Gumroad 签名相同的字节,再解析 application/x-www-form-urlencoded。
// src/routes/gumroad-ping.ts
import express from "express";
import { verifyGumroadSignature } from "../gumroad-verify";
import { fetchGumroadSale } from "../gumroad-sales";
import { sendViaRovela, RovelaError } from "../rovela";
export const gumroadPing = express.Router();
gumroadPing.post(
"/webhooks/gumroad/ping",
express.raw({ type: "application/x-www-form-urlencoded" }),
async (req, res) => {
const secret = process.env.GUMROAD_PING_SECRET;
const rawBody: Buffer = Buffer.isBuffer(req.body)
? req.body
: Buffer.from(String(req.body ?? ""), "utf8");
if (secret) {
const header = req.headers["x-gumroad-signature"];
const sig = Array.isArray(header) ? header[0] : header;
if (!verifyGumroadSignature(rawBody, sig, secret)) {
return res.status(400).send("invalid signature");
}
}
const params = new URLSearchParams(rawBody.toString("utf8"));
const saleId = params.get("sale_id") || "";
const resourceName = params.get("resource_name") || "sale";
const isTest = params.get("test") === "true";
if (!saleId) {
return res.status(400).send("missing sale_id");
}
// 下游较慢时先入队再 200,以便在 5 秒内确认。
try {
if (resourceName === "sale" && !isTest) {
await handleSalePing(saleId);
}
// resource_name === "refund":可用 gumroad-refund-{sale_id} 发单独通知
return res.status(200).send("ok");
} catch (err) {
console.error("gumroad ping handler failed", err);
// 可重试的非 2xx → Gumroad 会重试。更推荐入队后返回 200。
return res.status(500).send("handler failed");
}
},
);
async function handleSalePing(saleId: string) {
const sale = await fetchGumroadSale(saleId);
if (sale.refunded) return;
const to = String(sale.email || sale.purchase_email || "");
if (!to) return;
const product = String(sale.product_name || "your purchase");
const name = String(sale.full_name || "");
const total =
sale.formatted_total_price ||
sale.formatted_display_price ||
(typeof sale.price === "number" ? `${(sale.price / 100).toFixed(2)} USD` : "");
const orderNumber = String(sale.order_id ?? sale.id);
const license = sale.license_key ? String(sale.license_key) : "";
await sendSaleThankYou({
to,
name,
product,
total,
orderNumber,
license,
saleId: sale.id,
});
}
async function sendSaleThankYou(opts: {
to: string;
name: string;
product: string;
total: string;
orderNumber: string;
license: string;
saleId: string;
}) {
const templateId = process.env.ROVELA_SALE_TEMPLATE_ID;
const subject = opts.total
? `Thanks for buying ${opts.product} — ${opts.total}`
: `Thanks for buying ${opts.product}`;
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.license
? `<p><b>License key:</b> <code>${escapeHtml(opts.license)}</code></p>`
: ""
}
<p>Your download / access instructions are in your Gumroad library; this note is from us.</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.license ? `License key: ${opts.license}` : "",
"Your download / access instructions are in your Gumroad library; this note is from us.",
"If you did not make this purchase, contact support.",
]
.filter(Boolean)
.join("\n");
const idempotencyKey = `gumroad-sale-${opts.saleId}`.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 || "—",
order_number: opts.orderNumber,
license_key: opts.license || "",
},
},
idempotencyKey,
tags: { gumroad_resource: "sale", gumroad_sale_id: opts.saleId },
});
} else {
await sendViaRovela({
to: opts.to,
subject,
html,
text,
idempotencyKey,
tags: { gumroad_resource: "sale", gumroad_sale_id: opts.saleId },
});
}
} catch (err) {
if (err instanceof RovelaError) {
console.error("Rovela send failed", err.status, err.name, err.message);
}
throw err;
}
}
function escapeHtml(s: string) {
return s
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
}
挂载提示
import express from "express";
import { gumroadPing } from "./routes/gumroad-ping";
const app = express();
app.use(gumroadPing); // 先挂 raw body 路由
app.use(express.json()); // 其余 API 再用 JSON
若全局 urlencoded / JSON 解析器先跑,HMAC 校验会失败,因为签名覆盖的是 Gumroad 发出的精确字节。
Next.js App Router 路由
// app/api/webhooks/gumroad/ping/route.ts
import { NextRequest, NextResponse } from "next/server";
import crypto from "node:crypto";
import { sendViaRovela } from "@/lib/rovela";
export const runtime = "nodejs";
const MAX_SKEW_SECONDS = 300;
function verify(rawBody: string, header: string | null, secret: string) {
if (!header) return false;
const parts = Object.fromEntries(
header.split(",").map((p) => {
const i = p.indexOf("=");
return i === -1 ? [p, ""] : [p.slice(0, i).trim(), p.slice(i + 1).trim()];
}),
) as { t?: string; v1?: string };
if (!parts.t || !parts.v1 || !/^\d+$/.test(parts.t)) return false;
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > MAX_SKEW_SECONDS) return false;
const expected = Buffer.from(
crypto.createHmac("sha256", secret).update(parts.t + ".").update(rawBody).digest("hex"),
);
const received = Buffer.from(parts.v1);
return expected.length === received.length && crypto.timingSafeEqual(expected, received);
}
async function fetchSale(saleId: string) {
const token = process.env.GUMROAD_ACCESS_TOKEN!;
const url = new URL(`https://api.gumroad.com/v2/sales/${encodeURIComponent(saleId)}`);
url.searchParams.set("access_token", token);
const res = await fetch(url.toString());
const json = await res.json();
if (!json.success || !json.sale) throw new Error("sale not found");
return json.sale as {
id: string;
email?: string;
purchase_email?: string;
full_name?: string;
product_name?: string;
formatted_total_price?: string;
refunded?: boolean;
order_id?: number;
};
}
export async function POST(req: NextRequest) {
const secret = process.env.GUMROAD_PING_SECRET;
const rawBody = await req.text();
if (secret && !verify(rawBody, req.headers.get("x-gumroad-signature"), secret)) {
return new NextResponse("invalid signature", { status: 400 });
}
const params = new URLSearchParams(rawBody);
const saleId = params.get("sale_id");
const resourceName = params.get("resource_name") || "sale";
const isTest = params.get("test") === "true";
if (!saleId) {
return new NextResponse("missing sale_id", { status: 400 });
}
if (resourceName === "sale" && !isTest) {
const sale = await fetchSale(saleId);
const to = sale.email || sale.purchase_email;
if (to && !sale.refunded) {
const product = sale.product_name || "your purchase";
const total = sale.formatted_total_price || "";
await sendViaRovela({
to,
subject: total
? `Thanks for buying ${product} — ${total}`
: `Thanks for buying ${product}`,
html: `<p>Thanks for your purchase of <b>${product}</b>.</p><p>Total: ${total || "—"}</p>`,
text: `Thanks for your purchase of ${product}.\nTotal: ${total || "—"}`,
idempotencyKey: `gumroad-sale-${sale.id}`.slice(0, 256),
});
}
}
return new NextResponse("ok", { status: 200 });
}
可选:控制台模板
先创建一次模板(需要 full_access 或控制台),发布后,再从 Ping worker 用 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": "Gumroad sale thank-you",
"subject": "Thanks for buying {{product}} — {{total}}",
"html": "<p>Hi {{name}},</p><p>Thanks for buying <b>{{product}}</b>.</p><p>Total: {{total}}</p><p>Order #{{order_number}}</p>",
"text": "Hi {{name}}, thanks for buying {{product}}. Total: {{total}}. Order #{{order_number}}."
}'
然后 POST /templates/:id/publish。生产环境 Ping worker 可继续使用 sending_access Key——它们只调用 POST /emails。
记住:同一次发送不能把 template 与 html/text 混用,且 POST /emails/batch 会拒绝 template(发送邮件 API)。
生产检查清单
- 开启 Ping 签名,并在原始请求体上用时钟偏差 +
timingSafeEqual校验X-Gumroad-Signature(Gumroad Ping)。 - 发信前用
view_salestoken 回读GET /v2/sales/{sale_id};可选断言seller_id。 - 按
sale_id+resource_name去重;感谢信用Idempotency-Key = gumroad-sale-{sale_id}(发送邮件 API)。 - 5 秒内返回 2xx。若 Rovela 或 Sales API 较慢,先入队
{ saleId, resourceName }再异步发送。 - worker 优先使用
sending_accessKey;切勿暴露给客户端(鉴权)。 - 发送 html + text,或已发布的 template——不要空正文。
- 把买家相关字段写入 HTML / 模板变量前先转义(模板)。
- 除非要做实验发信,否则跳过
test=truePing。 - 需要运维可见性时,用
GET /emails/:id或 Rovela webhook 确认投递——发送接口的200只表示入队成功(快速开始)。
本文不覆盖
- 退款 / 争议类资源通知——同一套校验 → 拉取 → 发送模式,使用
gumroad-refund-{sale_id}与不同文案 - 自行生成 PDF 附件——见 带 PDF 附件的发票邮件
- Polar、Lemon Squeezy、Paddle 或 Stripe 的 webhook 签名——见上文链接的专文
- Rovela 入站或投递 webhook——与 Gumroad Ping 密钥无关
小结
Gumroad Ping 告诉你「发生了某事」;开启签名后,可选的 X-Gumroad-Signature 证明请求字节来自 Gumroad;GET /v2/sales/{sale_id} 才是买家与商品的权威来源。你的处理器把已确认的 sale 映射成感谢 / 交付邮件。Rovela 的 POST /emails 配上稳定的 Idempotency-Key(gumroad-sale-{sale_id}),即使 Ping 重试也只会按购买发一封。结账留在 Gumroad,投递交给 Rovela,密钥只放在服务器。