Skip to content

Webhook reference

Gumroad pings Keylight at:

POST https://api.keylight.dev/webhooks/gumroad/<tenantId>?token=<shared-secret>
Content-Type: application/x-www-form-urlencoded

The URL is generated for you in Settings → Integrations → Gumroad — see Gumroad setup if you haven’t enabled the integration yet.

The shared token query parameter is the only credential. Keylight looks up your account by tenantId and constant-time compares the supplied token against the stored gumroadWebhookToken.

Any of the following return a uniform 400 Invalid request (deliberately indistinguishable, to prevent account enumeration):

  • The account doesn’t exist.
  • The account exists but Gumroad isn’t enabled (gumroadWebhookToken is unset).
  • The supplied token doesn’t match.

If you rotate the token in the dashboard, the old URL stops working immediately — there is no grace period. Re-paste the new URL into your Gumroad product before rotating in production.

Keylight only acts on sale events. Everything else returns 204 No Content:

Form field conditionOutcome
resource_name=sale, test absent or not trueProcess the sale — mint license, write payment record.
resource_name=sale, test=trueTest ping — return 204, no side effects.
resource_name is anything else (refund, dispute, subscription_ended, …)Return 204, no side effects.

Gumroad’s sale ping is a flat form post. Keylight reads:

Form fieldRequiredUsed for
sale_idyesIdempotency key + PaymentRecord.id
emailyesLicense-record email + PaymentRecord.customerEmail
product_permalinkyesLooked up in tenant.gumroadProductMap to resolve the Keylight product
full_namenoPaymentRecord.customerName (omitted if absent)
product_namenoPaymentRecord.productName (defaults to Unknown product)
pricenoPaymentRecord.amountCents (defaults to 0)
currencynoNormalized to lowercase ISO (defaults to usd)

If sale_id or email is missing, Keylight returns 400 Missing required fields and does not mint a license. If product_permalink has no entry in gumroadProductMap, Keylight returns 400 No product mapping for permalink '<permalink>'.

Gumroad retries pings on non-2xx responses. Keylight guards against duplicate license issuance with a per-sale idempotency record in KV:

  • Key: gumroad:issued:<tenantId>:<sale_id>
  • TTL: 30 days

The flow is:

  1. Read the key. If it exists, return 200 { "received": true, "duplicate": true } immediately — no second license, no second payment record.
  2. Otherwise, write the key, then issue the license, then write the payment record.

The key is written before issuing, so concurrent replays of the same sale_id race on the KV write rather than minting twice. Eventually-consistent KV reads can still let two near-simultaneous duplicates through in rare cases; the downstream license-issuance path also guards against this.

On a valid, non-duplicate sale, Keylight reuses the same internal license-issuance path as the Stripe webhook. A synthetic Stripe-shaped session (id: gr_<sale_id>, metadata.product_id: <resolvedProductId>) is fed into issueLicenseFromSession, which:

  1. Generates a fresh license key with your app’s keyPrefix.
  2. Picks the product’s first key type for activation limit and expiry. (Gumroad’s payload doesn’t carry a key_type_id, so per-key-type pricing isn’t available — every Gumroad sale gets the product’s default key type. If you need different key types, sell them as separate Gumroad products.)
  3. Stores the LicenseRecord in Postgres + Durable Object.
  4. Writes a PaymentRecord to Postgres with source: 'gumroad'.

The HTTP response includes the raw license key once — same shape as the Stripe response. Keylight also emails the license key to the customer automatically when the webhook succeeds, so you don’t need a separate email step.

If the webhook fails, the license is still created but the email may not send. You can always re-send or issue a replacement from the dashboard.

StatusMeaning
200License issued and key returned in the response body.
200 {"received":true,"duplicate":true}Duplicate sale_id; no new license.
204Ignored (test ping or non-sale event).
400 Invalid requestBad/missing token, or tenant not found / Gumroad not enabled.
400 Missing required fieldssale_id or email missing from the form.
400 No product mapping for permalink '...'Permalink isn’t in gumroadProductMap.
400 Product '...' not foundPermalink mapped to a Keylight productId that no longer exists — clean up the mapping.
403Tenant lifecycle state forbids new license creation (suspended, etc.).