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 in KV:
- Key:
gumroad:issued:<tenantId>:<sale_id> - TTL: 30 days
The flow is:
- Read the key. If it exists, return
200 { "received": true, "duplicate": true }immediately — no second license, no second payment record. - 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.
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.) - Stores the
LicenseRecordin Postgres + Durable Object. - Writes a
PaymentRecordto Postgres withsource: '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.