Payments
29 Oct 2026
12 min read

Gumroad Webhook Integration: Secure Payment Processing for Micro-Apps

Step-by-step guide to integrating Gumroad payments with idempotent webhook handling and automatic entitlement management.

Gumroad Webhook Integration

SpaceZero includes a production-ready Gumroad integration.

Webhook Security

  • Plain shared-secret verification (compared against x-gumroad-signature header, custom_fields.secret, or the secret URL query parameter — no HMAC)
  • Secret configurable via GUMROAD_WEBHOOK_SECRET
  • Idempotent processing (upsert on unique gumroad_sale_id)

Flow

code

Customer → Gumroad Checkout → Gumroad Webhook → SpaceZero Edge Function → Supabase

Database Schema

sql

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()

);

Optimized User Lookup

The webhook uses a three-tier lookup strategy:

  1. Custom Field: user_id passed at checkout (fastest)
  2. Entitlement Cache: Previous purchase mapping
  3. Auth Admin API: Fallback (slowest)
typescript

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;

}

Setup (Gumroad resource subscriptions API)

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:

bash

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.

Testing

Use Gumroad's webhook testing tool or ngrok:

bash

ngrok http 3000

# Register the ngrok URL as post_url via the resource subscriptions API

Refund Handling

Refunds automatically revoke entitlements:

typescript

status: isRefunded ? 'revoked' : 'active'