Documentation menu▾

Selling with Gumroad

Keep selling on Gumroad and let Keygate issue licenses with device limits and offline checks: a Ping receiver that verifies each sale.

Gumroad can add a license key to any product, which is often how a first app gets sold. This guide explains what those keys do and do not do, and how to keep selling on Gumroad while Keygate issues and manages the licenses.

Built in keys or Keygate

A Gumroad key can be checked with a public API call, and Gumroad counts how often it has been checked. Everything else is left to you: Gumroad's own documentation says enforcing the key is up to the creator.

Gumroad keysKeygate
Device limitsNo, only a usage counterYes
Customers free an old deviceNoYes, in the app or the customer portal
Works offlineNoYes, with signed tokens
Keys stop after a refundNo, you disable keys yourselfYes, with the receiver below
Update feeds for Sparkle, Velopack, TauriNoYes, limited to licensed customers

How it works

  1. The customer buys on Gumroad, as today.
  2. Gumroad sends a Ping to a small receiver you run.
  3. Pings are not signed, so the receiver takes only the sale ID from the ping and reads the sale itself from the Gumroad API. A forged ping cannot create a license.
  4. The receiver creates the license through the Keygate admin API, and Keygate emails the customer their key. A refund revokes it.

Remove the License key block from the product's content in Gumroad, so customers receive only one key.

Set it up

  1. In Keygate, create an API key with the licenses:write scope, limited to the product you sell. Note the product ID and the ID of each plan.
  2. In Gumroad, open Settings, then Advanced, and create an application. Generate an access token for it with the view_sales scope.
  3. On the same page, turn on Gumroad's Ping and enter the address of your receiver.
  4. Match each Gumroad product ID to a Keygate plan in PLANS below, and run the receiver anywhere with a public HTTPS address, with KEYGATE_URL, KEYGATE_API_KEY, KEYGATE_PRODUCT_ID and GUMROAD_ACCESS_TOKEN set.

The ping receiver

It needs Node.js 18 or later and no packages:

webhook.mjs
import { createServer } from "node:http";

const KEYGATE = process.env.KEYGATE_URL; // https://licenses.example.com
const API_KEY = process.env.KEYGATE_API_KEY; // kg_live_... with licenses:write
const PRODUCT_ID = process.env.KEYGATE_PRODUCT_ID;
const GUMROAD_TOKEN = process.env.GUMROAD_ACCESS_TOKEN; // with the view_sales scope

// Gumroad product ID on the left, Keygate plan ID on the right.
const PLANS = {
  "32-nPAicqbLj8B_WswVlMw==": "8c1e2b9a-...",
};

const headers = { Authorization: `Bearer ${API_KEY}`, "Content-Type": "application/json" };

// Each license remembers its Gumroad sale in external_customer_id.
async function findLicense(saleId) {
  const id = encodeURIComponent(`gr_sale_${saleId}`);
  const body = await fetch(`${KEYGATE}/api/v1/admin/licenses?external_customer_id=${id}`, {
    headers,
  }).then((r) => r.json());
  return body.data.licenses[0];
}

async function post(path, body = {}) {
  const res = await fetch(`${KEYGATE}/api/v1/admin/${path}`, {
    method: "POST",
    headers,
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`${path} answered ${res.status}`);
}

async function getSale(id) {
  const url = `https://api.gumroad.com/v2/sales/${encodeURIComponent(id)}?access_token=${GUMROAD_TOKEN}`;
  const body = await fetch(url).then((r) => r.json());
  if (!body.success) throw new Error(`Gumroad has no sale ${id}`);
  return body.sale;
}

async function handle(ping) {
  // Pings are not signed. Use only the sale ID and read the sale itself from Gumroad.
  const sale = await getSale(ping.get("sale_id"));
  const license = await findLicense(sale.id);

  if (sale.refunded) {
    if (license && license.status !== "revoked") await post(`licenses/${license.id}/revoke`);
    return;
  }

  const plan = PLANS[sale.product_id];
  if (!plan || license) return; // another product, or a repeated ping
  await post("licenses", {
    product_id: PRODUCT_ID,
    plan_id: plan,
    email: sale.email,
    external_customer_id: `gr_sale_${sale.id}`,
  });
}

createServer(async (req, res) => {
  const chunks = [];
  for await (const chunk of req) chunks.push(chunk);
  const ping = new URLSearchParams(Buffer.concat(chunks).toString());
  try {
    await handle(ping);
    res.writeHead(200).end();
  } catch (err) {
    console.error(err);
    res.writeHead(500).end(); // Gumroad retries 5xx answers a few times
  }
}).listen(3000);

Each license keeps its Gumroad sale in external_customer_id, so a repeated ping finds the license it already created, and a refund finds the license to revoke.

Things to know

  • Gumroad expects an answer within five seconds and retries only server errors, a few times over the following hour. Pings can arrive twice or out of order, which the receiver handles.
  • This receiver covers one time purchases and refunds. For memberships, also handle the end of a subscription, or sell subscriptions through Stripe, which Keygate renews and ends on its own.
  • Your app then checks licenses against Keygate, not Gumroad. See Activating licenses in your app.

Last updated October 4, 2026