Webhook reference
Gumroad pings Keylight at:
POST https://api.keylight.dev/webhooks/gumroad/<tenantId>?token=<shared-secret>Content-Type: application/x-www-form-urlencodedThe URL is generated for you in Settings → Integrations → Gumroad — see Gumroad setup if you haven’t enabled the integration yet.
Authentication
Section titled “Authentication”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 (
gumroadWebhookTokenis 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.
Events Keylight processes
Section titled “Events Keylight processes”Keylight only acts on sale events. Everything else returns 204 No Content:
| Form field condition | Outcome |
|---|---|
resource_name=sale, test absent or not true | Process the sale — mint license, write payment record. |
resource_name=sale, test=true | Test ping — return 204, no side effects. |
resource_name is anything else (refund, dispute, subscription_ended, …) | Return 204, no side effects. |
Fields read from the ping
Section titled “Fields read from the ping”Gumroad’s sale ping is a flat form post. Keylight reads:
| Form field | Required | Used for |
|---|---|---|
sale_id | yes | Idempotency key + PaymentRecord.id |
email | yes | License-record email + PaymentRecord.customerEmail |
product_permalink | yes | Looked up in tenant.gumroadProductMap to resolve the Keylight product |
full_name | no | PaymentRecord.customerName (omitted if absent) |
product_name | no | PaymentRecord.productName (defaults to Unknown product) |
price | no | PaymentRecord.amountCents (defaults to 0) |
currency | no | Normalized 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>'.
Idempotency
Section titled “Idempotency”Gumroad retries pings on non-2xx responses. Keylight guards against duplicate license issuance with a per-sale idempotency record, keyed on <tenantId>:<sale_id> and retained for 30 days.
The flow is:
- Read the record. If it exists, return
200 { "received": true, "duplicate": true }immediately — no second license, no second payment record. - Otherwise, write the record, then issue the license, then write the payment record.
The record is written before issuing, so concurrent replays of the same sale_id race on that write rather than minting twice. In rare cases two near-simultaneous duplicates can still get through; the downstream license-issuance path also guards against this.
What gets minted
Section titled “What gets minted”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:
- Generates a fresh license key with your app’s
keyPrefix. - 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.) - Records the license.
- Records the payment 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.
Status codes summary
Section titled “Status codes summary”| Status | Meaning |
|---|---|
200 | License issued and key returned in the response body. |
200 {"received":true,"duplicate":true} | Duplicate sale_id; no new license. |
204 | Ignored (test ping or non-sale event). |
400 Invalid request | Bad/missing token, or tenant not found / Gumroad not enabled. |
400 Missing required fields | sale_id or email missing from the form. |
400 No product mapping for permalink '...' | Permalink isn’t in gumroadProductMap. |
400 Product '...' not found | Permalink mapped to a Keylight productId that no longer exists — clean up the mapping. |
403 | Tenant lifecycle state forbids new license creation (suspended, etc.). |
Related
Section titled “Related”- Gumroad setup — enable the integration and map permalinks.
- How Gumroad payments flow — end-to-end sequence diagram.
- Stripe manual mode — same one-way webhook shape, with Stripe instead of Gumroad.