Step-by-step guide to integrating Gumroad payments with idempotent webhook handling and automatic entitlement management.
SpaceZero includes a production-ready Gumroad integration.
x-gumroad-signature header, custom_fields.secret, or the secret URL query parameter — no HMAC)GUMROAD_WEBHOOK_SECRETgumroad_sale_id)Customer → Gumroad Checkout → Gumroad Webhook → SpaceZero Edge Function → Supabase
CREATE TABLE app_entitlements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
email TEXT NOT NULL,
product_id TEXT NOT NULL,
gumroad_sale_id TEXT UNIQUE NOT NULL,
status TEXT NOT NULL DEFAULT 'active', -- active, revoked, refunded
metadata JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
The webhook uses a three-tier lookup strategy:
user_id passed at checkout (fastest)async function resolveUserId(supabase, email, customFields) {
// 1. Check custom_fields.user_id
if (customFields?.user_id) return customFields.user_id;
// 2. Check existing entitlement
const { data } = await supabase
.from('app_entitlements')
.select('user_id')
.eq('email', email)
.not('user_id', 'is', null)
.single();
if (data?.user_id) return data.user_id;
// 3. Fallback to auth admin
const { data: usersData } = await supabase.auth.admin.listUsers({});
return usersData.users.find(u => u.email === email)?.id || null;
}
Gumroad no longer exposes a Ping field in Settings → Advanced; webhooks are account-level (not per-product) and registered via the API. Create an access token under Settings → Advanced → Applications, then register the sale subscription:
curl -X PUT https://api.gumroad.com/v2/resource_subscriptions \
-d "access_token=$GUMROAD_ACCESS_TOKEN" \
-d "resource_name=sale" \
-d "post_url=https://your-app.space/api/webhooks/gumroad?secret=$GUMROAD_WEBHOOK_SECRET"
The shared secret rides in the ping URL query string — the handler accepts it there, in the X-Gumroad-Signature header, or as custom_fields.secret in the body.
Use Gumroad's webhook testing tool or ngrok:
ngrok http 3000
# Register the ngrok URL as post_url via the resource subscriptions API
Refunds automatically revoke entitlements:
status: isRefunded ? 'revoked' : 'active'