Gumroad Ping Webhook Email with Rovela (2026): X-Gumroad-Signature + Sales API + Idempotency-Key
Verify optional X-Gumroad-Signature on the raw form body, re-fetch the sale via GET /v2/sales/{sale_id}, and send branded thank-you/delivery mail via Rovela POST /emails with Idempotency-Key = gumroad-sale-{sale_id}. Gumroad owns checkout; Rovela delivers. Distinct from Polar, Lemon Squeezy, Paddle, and Stripe webhook posts.
Gumroad owns checkout — Rovela delivers the thank-you
Gumroad Ping is a real-time HTTP POST to a URL you configure in account settings whenever someone buys (or later refunds) one of your products. The payload is application/x-www-form-urlencoded, not JSON. Gumroad’s built-in buyer receipt is store-side; it is not a custom HTTP send from your verified product domain with your template and your Idempotency-Key. Rovela queues, DKIM-signs, and delivers that custom transactional mail when your Ping handler calls POST https://api.rovela.dev/emails (Send Email API, Quick Start, Authentication, Templates).
This guide wires a Gumroad Ping endpoint that:
- Optionally verifies
X-Gumroad-Signature(t=…,v1=…HMAC-SHA256 overt.+ raw body) when signing is enabled (Verifying a ping) - Treats the Ping as a trigger: takes
sale_id, then confirms the sale withGET https://api.gumroad.com/v2/sales/{sale_id}(view_salestoken) (Gumroad API) - On
resource_name=sale(and a non-refunded sale), sends a delivery / thank-you through Rovela - Sets a stable
Idempotency-Keyso at-least-once Ping retries never double-mail
Rovela does not create Gumroad products, configure Ping URLs, verify X-Gumroad-Signature, or call the Gumroad Sales API. Gumroad owns commerce and Ping delivery. Your app owns verify → fetch sale → map fields → template variables. Rovela only delivers.
Distinct from:
- Polar.sh Webhook Confirmation Email — Polar webhook signatures + order/subscription events, not Gumroad form-encoded Ping
- Lemon Squeezy Webhook Confirmation Email —
X-Signature+ JSON:APIorder_created/subscription_created - Paddle Billing Webhook Email —
Paddle-Signature+ Billing notifications - Stripe Webhook Receipt Email —
Stripe-Signature+ Checkout/Invoice/PaymentIntent events
What Gumroad does vs what Rovela sends
| Concern | Owner |
|---|---|
| Products, checkout, payouts, built-in receipts | Gumroad |
Ping URL + optional signing secret + X-Gumroad-Signature | Your server + Gumroad |
GET /v2/sales/{sale_id} authenticity check | Your server + Gumroad OAuth (view_sales) |
| Choosing when to mail (sale vs refund vs test) | Your app |
| Buyer email, product name, price, license_key | Ping fields and/or Sales API sale object (your mapping) |
POST /emails, DKIM, queue, bounces | Rovela |
Idempotency-Key = gumroad-{resource_name}-{sale_id} | Your Ping handler |
A 200 from Rovela means accepted and queued, not “already in the inbox” (Quick Start).
Prerequisites
- A verified sending domain in the Rovela dashboard. Unverified
fromdomains are refused with400(Quick Start). Prefer something likeorders.example.comormail.example.com. - An API key shaped like
re_…. Prefersending_accessfor the Ping worker (Authentication). - Gumroad Ping URL pointed at your HTTPS endpoint (Gumroad Ping). Turn signing on in account settings and store the rotate-able secret — until then the endpoint URL is the only credential on a request.
- A Gumroad OAuth access token with
view_salesso you can re-read the sale after a Ping (Gumroad API / help: create an application). - Keep
GUMROAD_PING_SECRET,GUMROAD_ACCESS_TOKEN,ROVELA_API_KEY, andROVELA_FROMonly on the server. Never in a browser bundle, marketing site, or mobile app.
Environment
export GUMROAD_PING_SECRET=your_ping_signing_secret
export GUMROAD_ACCESS_TOKEN=your_view_sales_token
export GUMROAD_EXPECTED_SELLER_ID=your_seller_id # optional hard check
export ROVELA_API_KEY=re_your_sending_access_key
export ROVELA_FROM='Acme <orders@mail.example.com>'
# optional dashboard template UUID
export ROVELA_SALE_TEMPLATE_ID=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee
Rotate Rovela keys by creating a new one, deploying, then revoking the old key (Authentication). Rotating the Gumroad Ping secret takes effect immediately — update the worker copy right after rotating (Verifying a ping).
Why authenticity is two steps on Gumroad
Gumroad documents Ping delivery carefully (Gumroad Ping):
- Delivery is at least once; the same Ping can arrive more than once
- Ordering is not guaranteed (a refund Ping can beat the sale Ping)
- Deduplicate on
sale_id+resource_name, notsale_idalone — a refund reuses the sale id - Your endpoint has 5 seconds to respond; acknowledge first when work is slow
- Retries: 1m / 3m / 10m / 1h, and only for 499 / 500 / 502 / 503 / 504
- Signing is off by default; when on,
X-Gumroad-Signature: t=…,v1=…is HMAC-SHA256 oft.+ raw bytes - Even with a signature, treat Ping as a trigger: take
sale_id, read the sale back through the API, and reconcile periodically
So the safe mail path is: verify signature (if enabled) → fetch sale → only then POST /emails.
Why Idempotency-Key matters for Gumroad
Rovela’s Idempotency-Key header (up to 256 characters) makes a retry of the same send within 24 hours return the original response instead of mailing again (Send Email API).
Recommended keys:
| Key | Use when |
|---|---|
gumroad-sale-{sale_id} | One thank-you / delivery email per purchase |
gumroad-refund-{sale_id} | One refund notice per sale (if you add that path later) |
gumroad-{resource_name}-{sale_id} | Generic pattern |
Do not key off the signature: a retried Ping re-signs with a new t, so v1 changes while the payload stays the same (Verifying a ping).
Skip mailing when Ping test=true (account “Send test ping” or buying your own product) unless you intentionally want sandbox mail.
Shared Rovela sender (server-only)
// 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 is mutually exclusive with html/text — see Templates docs
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 };
}
Always prefer both html and text when you are not using a dashboard template. Escape any buyer-controlled strings before putting them in HTML or in template variables — template substitution is not HTML-escaped (Templates).
Verify X-Gumroad-Signature on the raw body
When signing is enabled, parse t and v1 from X-Gumroad-Signature, reject skew > 5 minutes, and compare HMAC-SHA256 hex digests in constant time. Sign the raw body bytes — do not re-encode form fields (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);
}
If signing is still off in your Gumroad settings, skip this header check only while you finish enabling it — and still re-fetch the sale with your private token before mailing.
Fetch the sale (Ping is not payment proof)
// 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;
}
Prefer sale.email / sale.purchase_email and product fields from this response over trusting Ping form values alone.
Express: raw body → verify → fetch → send
Use a raw-body parser on the Ping route so signature verification sees the same bytes Gumroad signed. Then parse 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");
}
// Acknowledge quickly if your downstream is slow — enqueue then 200.
try {
if (resourceName === "sale" && !isTest) {
await handleSalePing(saleId);
}
// resource_name === "refund": optional separate notice with gumroad-refund-{sale_id}
return res.status(200).send("ok");
} catch (err) {
console.error("gumroad ping handler failed", err);
// Non-2xx in the retryable set → Gumroad retries. Prefer enqueue-then-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, """);
}
Mounting tip
import express from "express";
import { gumroadPing } from "./routes/gumroad-ping";
const app = express();
app.use(gumroadPing); // raw body route first
app.use(express.json()); // JSON for the rest of the API
If a global urlencoded / JSON parser runs first, HMAC verification fails because the signature covers the exact bytes Gumroad sent.
Next.js App Router route
// 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 });
}
Optional: dashboard templates
Create templates once (needs full_access or the dashboard), publish them, then send with template.id + variables from the Ping worker (Templates):
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}}."
}'
Then POST /templates/:id/publish. Production Ping workers can keep using a sending_access key — they only call POST /emails.
Remember: template cannot be mixed with html/text on the same send, and POST /emails/batch rejects template (Send Email API).
Production checklist
- Enable Ping signing and verify
X-Gumroad-Signatureon the raw body with skew +timingSafeEqual(Gumroad Ping). - Re-fetch
GET /v2/sales/{sale_id}with aview_salestoken before mailing; optionally assertseller_id. - Deduplicate on
sale_id+resource_name; useIdempotency-Key = gumroad-sale-{sale_id}for the thank-you (Send Email API). - Return 2xx within 5 seconds. If Rovela or the Sales API is slow, enqueue
{ saleId, resourceName }and send asynchronously. - Prefer
sending_accesskeys in the worker; never expose them to the client (Authentication). - Send html + text, or a published template — not an empty body.
- Escape buyer-influenced fields before HTML / template variables (Templates).
- Skip
test=truePings unless you want lab mail. - Confirm delivery with
GET /emails/:idor Rovela webhooks if you need ops visibility — a send200is queue acceptance only (Quick Start).
What this does not cover
- Refund / dispute resource notifications — same verify → fetch → send pattern with
gumroad-refund-{sale_id}and different copy - Attaching a custom PDF you generate yourself — see Send an Invoice Email with a PDF Attachment
- Polar, Lemon Squeezy, Paddle, or Stripe webhook signatures — see the dedicated posts linked above
- Rovela inbound or delivery webhooks — separate from Gumroad’s Ping secret
Summary
Gumroad Ping tells you that something happened; optional X-Gumroad-Signature proves the request bytes came from Gumroad when signing is on; GET /v2/sales/{sale_id} is the source of truth for the buyer and product. Your handler maps a confirmed sale to a thank-you / delivery email. Rovela’s POST /emails with a stable Idempotency-Key (gumroad-sale-{sale_id}) delivers one message per purchase even when Ping retries. Keep checkout in Gumroad, delivery in Rovela, and secrets on the server.