← Back to blog

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.

Updated November 4, 2026
Gumroad Ping Webhook Email with Rovela (2026): X-Gumroad-Signature + Sales API + Idempotency-Key

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:

  1. Optionally verifies X-Gumroad-Signature (t=…,v1=… HMAC-SHA256 over t. + raw body) when signing is enabled (Verifying a ping)
  2. Treats the Ping as a trigger: takes sale_id, then confirms the sale with GET https://api.gumroad.com/v2/sales/{sale_id} (view_sales token) (Gumroad API)
  3. On resource_name=sale (and a non-refunded sale), sends a delivery / thank-you through Rovela
  4. Sets a stable Idempotency-Key so 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:

What Gumroad does vs what Rovela sends

ConcernOwner
Products, checkout, payouts, built-in receiptsGumroad
Ping URL + optional signing secret + X-Gumroad-SignatureYour server + Gumroad
GET /v2/sales/{sale_id} authenticity checkYour server + Gumroad OAuth (view_sales)
Choosing when to mail (sale vs refund vs test)Your app
Buyer email, product name, price, license_keyPing fields and/or Sales API sale object (your mapping)
POST /emails, DKIM, queue, bouncesRovela
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

  1. A verified sending domain in the Rovela dashboard. Unverified from domains are refused with 400 (Quick Start). Prefer something like orders.example.com or mail.example.com.
  2. An API key shaped like re_…. Prefer sending_access for the Ping worker (Authentication).
  3. 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.
  4. A Gumroad OAuth access token with view_sales so you can re-read the sale after a Ping (Gumroad API / help: create an application).
  5. Keep GUMROAD_PING_SECRET, GUMROAD_ACCESS_TOKEN, ROVELA_API_KEY, and ROVELA_FROM only 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, not sale_id alone — 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 of t. + 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:

KeyUse 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, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;");
}

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

  1. Enable Ping signing and verify X-Gumroad-Signature on the raw body with skew + timingSafeEqual (Gumroad Ping).
  2. Re-fetch GET /v2/sales/{sale_id} with a view_sales token before mailing; optionally assert seller_id.
  3. Deduplicate on sale_id + resource_name; use Idempotency-Key = gumroad-sale-{sale_id} for the thank-you (Send Email API).
  4. Return 2xx within 5 seconds. If Rovela or the Sales API is slow, enqueue { saleId, resourceName } and send asynchronously.
  5. Prefer sending_access keys in the worker; never expose them to the client (Authentication).
  6. Send html + text, or a published template — not an empty body.
  7. Escape buyer-influenced fields before HTML / template variables (Templates).
  8. Skip test=true Pings unless you want lab mail.
  9. Confirm delivery with GET /emails/:id or Rovela webhooks if you need ops visibility — a send 200 is 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.