Skip to content

How Gumroad payments flow

Gumroad integration is one-way: Gumroad pushes sale events to Keylight, Keylight mints a license, you email the key. There is no OAuth, no Gumroad API call from Keylight, and no outbound license.created webhook (unlike Stripe Connect) — the response from the ping is your only chance to capture the raw key.

Customerpays onGumroad checkoutGumroadfires pingresource_name=saleKeylightmints license,writes paymentYour forwarderreads response,emails keyemail license key to customer

Standard Gumroad checkout. Nothing Keylight-specific happens here. On success, Gumroad redirects the customer to Gumroad’s “Thanks for your purchase” page (or your custom thank-you redirect, if configured) and queues a ping for the sale.

Gumroad posts an application/x-www-form-urlencoded body to your configured ping URL:

POST /webhooks/gumroad/<tenantId>?token=<shared-secret>

Key fields in the form body include resource_name, sale_id, email, full_name, product_name, price, currency, and product_permalink. The full field list and what Keylight does with each is in the Webhook reference.

Gumroad retries pings on non-2xx responses. Keylight is idempotent on sale_id (see below).

Keylight runs through, in order:

  1. Auth. Token query param compared against tenant.gumroadWebhookToken.
  2. Lifecycle check. Tenant must be in a state that allows new license creation.
  3. Event filter. Skip anything that isn’t a real sale (test pings, refunds, etc. → 204).
  4. Idempotency. Read gumroad:issued:<tenantId>:<sale_id> from KV. If present, return 200 {"received":true,"duplicate":true}.
  5. Permalink resolution. Look up tenant.gumroadProductMap[product_permalink] → Keylight productId. Reject 400 on miss.
  6. Issue license. Generate the key, store hash + masked form, write LicenseRecord to Postgres + Durable Object.
  7. Write payment record. PaymentRecord in Postgres with source: 'gumroad'. Best-effort — failure is logged but does not fail the response, so Gumroad doesn’t retry and risk a double-issue.

The HTTP response carries the raw license key in the body — once, and only once.

The customer launches your app, pastes the key, and the SDK calls /activate. First activation consumes one slot; subsequent launches re-use the cached Ed25519 lease. See How it works for the SDK side of the handshake.

Gumroad’s payload doesn’t carry a key_type_id. Every Gumroad sale gets the product’s first key type — its activation limit and expiry. If you need to sell multiple key types (e.g. “personal” and “team”) via Gumroad, model them as separate Gumroad products and map each permalink to a different Keylight productId.

For per-key-type pricing within a single product, use Stripe Connect — that flow does pass key_type_id through checkout-session metadata.

SymptomLikely cause
400 Invalid request on every pingToken in URL doesn’t match tenant.gumroadWebhookToken (rotated? typo?)
400 Missing required fieldsGumroad payload missing sale_id or email (only seen on hand-rolled retries)
400 No product mapping for permalink '...'Permalink isn’t in Settings → Integrations → Gumroad
403Tenant lifecycle state forbids new licenses
Customer paid but no email arrivedYour ping forwarder didn’t capture the response, or your sender is down — re-issue from the dashboard
Duplicate licensesShould be impossible due to sale_id idempotency; if seen, file a bug with the sale_id