← 返回博客

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

更新于 2026年11月4日
Gumroad Ping Webhook 邮件 + Rovela(2026):X-Gumroad-Signature、Sales API 与幂等键

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 端点,做到:

  1. 在开启签名时,可选校验 X-Gumroad-Signature(t=…,v1=…,对 t. + 原始请求体做 HMAC-SHA256)(Verifying a ping)
  2. 把 Ping 当作触发器:取出 sale_id,再用 GET https://api.gumroad.com/v2/sales/{sale_id}(view_sales token)确认这笔销售(Gumroad API)
  3. 在 resource_name=sale(且未退款)时,通过 Rovela 发送交付 / 感谢信
  4. 设置稳定的 Idempotency-Key,避免至少一次投递的 Ping 重试导致重复发信

Rovela 不会创建 Gumroad 商品、配置 Ping URL、校验 X-Gumroad-Signature,也不会代你调用 Gumroad Sales API。 Gumroad 负责商业与 Ping 投递;你的应用负责校验 → 拉取销售 → 映射字段 → 模板变量;Rovela 只负责投递。

与下列文章区分:

Gumroad 做什么 vs Rovela 发什么

关注点负责方
商品、结账、打款、内置收据Gumroad
Ping URL + 可选签名密钥 + X-Gumroad-Signature你的服务器 + Gumroad
GET /v2/sales/{sale_id} 真实性核对你的服务器 + Gumroad OAuth(view_sales)
何时发信(sale / refund / test)你的应用
买家邮箱、商品名、价格、license_keyPing 字段与/或 Sales API 的 sale 对象(由你映射)
POST /emails、DKIM、队列、退信Rovela
Idempotency-Key = gumroad-{resource_name}-{sale_id}你的 Ping 处理器

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

前置条件

  1. 在 Rovela 控制台配置已验证发信域名。未验证的 from 域名会以 400 拒绝(快速开始)。建议使用 orders.example.com 或 mail.example.com。
  2. API Key 形如 re_…。Ping worker 优先使用 sending_access(鉴权)。
  3. 将 Gumroad Ping URL 指向你的 HTTPS 端点(Gumroad Ping)。在账户设置中打开签名并保存可轮换密钥——在此之前,端点 URL 是请求上唯一的凭据。
  4. 准备带 view_sales 的 Gumroad OAuth access token,以便在 Ping 之后回读销售(Gumroad API)。
  5. 将 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, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;");
}

挂载提示

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)。

生产检查清单

  1. 开启 Ping 签名,并在原始请求体上用时钟偏差 + timingSafeEqual 校验 X-Gumroad-Signature(Gumroad Ping)。
  2. 发信前用 view_sales token 回读 GET /v2/sales/{sale_id};可选断言 seller_id。
  3. 按 sale_id + resource_name 去重;感谢信用 Idempotency-Key = gumroad-sale-{sale_id}(发送邮件 API)。
  4. 5 秒内返回 2xx。若 Rovela 或 Sales API 较慢,先入队 { saleId, resourceName } 再异步发送。
  5. worker 优先使用 sending_access Key;切勿暴露给客户端(鉴权)。
  6. 发送 html + text,或已发布的 template——不要空正文。
  7. 把买家相关字段写入 HTML / 模板变量前先转义(模板)。
  8. 除非要做实验发信,否则跳过 test=true Ping。
  9. 需要运维可见性时,用 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,密钥只放在服务器。