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 keys | Keygate | |
|---|---|---|
| Device limits | No, only a usage counter | Yes |
| Customers free an old device | No | Yes, in the app or the customer portal |
| Works offline | No | Yes, with signed tokens |
| Keys stop after a refund | No, you disable keys yourself | Yes, with the receiver below |
| Update feeds for Sparkle, Velopack, Tauri | No | Yes, limited to licensed customers |
How it works
- The customer buys on Gumroad, as today.
- Gumroad sends a Ping to a small receiver you run.
- 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.
- 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
- In Keygate, create an API key with the
licenses:writescope, limited to the product you sell. Note the product ID and the ID of each plan. - In Gumroad, open Settings, then Advanced, and create an application. Generate an access token for it with the
view_salesscope. - On the same page, turn on Gumroad's Ping and enter the address of your receiver.
- Match each Gumroad product ID to a Keygate plan in
PLANSbelow, and run the receiver anywhere with a public HTTPS address, withKEYGATE_URL,KEYGATE_API_KEY,KEYGATE_PRODUCT_IDandGUMROAD_ACCESS_TOKENset.
The ping receiver
It needs Node.js 18 or later and no packages:
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