Reference
20 Nov 2026
18 min read

SpaceZero API Reference: Edge Functions, Webhooks, and Client SDK

Complete API reference for SpaceZero edge functions including authentication, payments, newsletter, and webhooks.

SpaceZero API Reference

Complete reference for all SpaceZero edge functions.

Base URL

code

Production: https://your-app.space/api/

Development: http://localhost:3000/api/

Authentication

Auth is handled entirely client-side by the Supabase JS client (session persisted in localStorage). The edge functions are public endpoints — they do not authenticate users, they only validate input and rate-limit by IP.

No API keys needed for client calls.

Rate Limiting

EndpointLimitWindow
/api/auth/check-user10 req60s
/api/auth/send-email5 req60s
/api/newsletter (POST)10 req60s
/api/newsletter/unsubscribe (GET)20 req60s
/api/webhooks/gumroadUnlimited-

Exceeded → 429 Too Many Requests

Endpoints

GET `/api/health`

Health check for load balancers.

Response:

json

{

"status": "ok",

"service": "spacezero-api",

"framework": "cloudflare-pages-functions",

"version": "0.1.0",

"timestamp": "2026-12-10T00:00:00.000Z",

"bindings": {

"hasSupabaseServiceRole": true,

"hasGumroadSecret": true,

"appUrl": "https://your-app.space"

}

}


POST `/api/auth/check-user`

Check if email exists in Supabase Auth.

Request:

json

{

"email": "[email protected]"

}

Response:

json

{

"exists": true

}

Errors:

  • 400: Invalid email format
  • 429: Rate limited

POST `/api/auth/send-email`

Send transactional email via Resend.

Request:

json

{

"email": "[email protected]",

"type": "password_reset" | "email_confirmation",

"token": "reset-token",

"redirectUrl": "https://your-app.space/reset-password"

}

Response:

json

{

"sent": true,

"email": "[email protected]",

"type": "password_reset",

"note": "Only present when RESEND_API_KEY is not set — email is simulated."

}

Errors:

  • 400: Invalid email or missing type
  • 429: Rate limited
  • 500: Resend API error

POST `/api/auth/signup`

Sign up with email/password (handled by Supabase client, not direct API).


POST `/api/auth/signin`

Login with email/password (handled by Supabase client).


POST `/api/auth/signout`

Logout (handled by Supabase client).


POST `/api/newsletter`

Subscribe email to ConvertKit.

Request:

json

{

"email": "[email protected]"

}

Response:

json

{

"subscribed": true,

"email": "[email protected]",

"state": "active"

}

States:

  • active - Confirmed subscriber
  • unconfirmed - Pending double opt-in
  • cancelled - Unsubscribed

Errors:

  • 400: Invalid email
  • 409: Already subscribed
  • 429: Rate limited
  • 500: ConvertKit API error

GET `/api/newsletter/unsubscribe`

Unsubscribe email from ConvertKit.

Query Parameters:

Response: HTML page confirming unsubscribe.

Errors:

  • 400: Invalid email
  • 404: Email not found
  • 500: ConvertKit API error

POST `/api/webhooks/gumroad`

Gumroad purchase webhook handler.

Headers:

code

Content-Type: application/x-www-form-urlencoded

X-Gumroad-Signature: your-webhook-secret

Body (form data):

code

sale_id=12345&[email protected]&product_id=abc&refunded=false&custom_fields[user_id]=uuid

Response:

json

{

"received": true,

"sale_id": "12345",

"buyer_email": "[email protected]",

"product_id": "abc",

"user_id": "uuid",

"status": "active",

"db_status": "written",

"timestamp": "2026-12-10T00:00:00.000Z"

}

Verification:

  • Plain shared-secret comparison (no HMAC) via X-Gumroad-Signature header, custom_fields.secret in the body, or a secret query parameter on the ping URL
  • Only enforced when GUMROAD_WEBHOOK_SECRET is configured

Errors:

  • 400: Missing sale_id or email (or unsupported Content-Type)
  • 401: Invalid signature
  • 500: Processing error

Client SDK (Supabase)

SpaceZero uses Supabase JavaScript client for all database/auth operations.

Initialization

typescript

// src/lib/supabase.ts

import { createClient } from '@supabase/supabase-js';

 

export const supabase = createClient(

import.meta.env.VITE_SUPABASE_URL,

import.meta.env.VITE_SUPABASE_ANON_KEY

);

Auth

typescript

// Sign up

const { data, error } = await supabase.auth.signUp({

email: '[email protected]',

password: 'password123',

options: { emailRedirectTo: 'https://your-app.space/confirm' }

});

 

// Login

const { data, error } = await supabase.auth.signInWithPassword({

email: '[email protected]',

password: 'password123',

});

 

// Logout

await supabase.auth.signOut();

 

// Password reset

await supabase.auth.resetPasswordForEmail('[email protected]', {

redirectTo: 'https://your-app.space/reset-password',

});

 

// Update password (after reset)

await supabase.auth.updateUser({ password: 'newpassword' });

 

// OAuth

await supabase.auth.signInWithOAuth({

provider: 'google',

options: { redirectTo: 'https://your-app.space/' }

});

 

// Session

const { data: { session } } = await supabase.auth.getSession();

 

// User

const { data: { user } } = await supabase.auth.getUser();

Database

typescript

// Query with RLS (user must be authenticated)

const { data, error } = await supabase

.from('user_profiles')

.select('*')

.eq('id', user.id)

.single();

 

// Upsert (RLS checks user_id)

const { data, error } = await supabase

.from('user_profiles')

.upsert({ id: user.id, full_name: 'New Name' });

 

// Update

const { data, error } = await supabase

.from('user_profiles')

.update({ full_name: 'New Name' })

.eq('id', user.id);

 

// Delete (RLS requires ownership)

const { data, error } = await supabase

.from('app_entitlements')

.delete()

.eq('gumroad_sale_id', saleId)

.eq('user_id', user.id);

Storage

typescript

// Upload (original file, original extension)

const fileId = `${user.id}/${Date.now()}.${file.name.split('.').pop()}`;

const { data, error } = await supabase.storage

.from('avatars')

.upload(fileId, file, { upsert: true });

 

// Public URL

const { data: { publicUrl } } = supabase.storage

.from('avatars')

.getPublicUrl(data.path);

 

// Delete

await supabase.storage.from('avatars').remove([path]);

Realtime

typescript

const channel = supabase

.channel('entitlements')

.on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'app_entitlements' }, payload => {

console.log('New entitlement:', payload.new);

})

.subscribe();

 

channel.unsubscribe();

Error Responses

All endpoints return consistent error format:

json

{

"error": "Human-readable error message",

"details": "Optional technical details"

}

HTTP Status Codes

CodeMeaning
200Success
400Bad Request (validation)
401Unauthorized (signature)
404Not Found
409Conflict (already exists)
429Too Many Requests
500Internal Server Error
503Service Unavailable

Webhook Retry Policy

Gumroad retries failed webhooks with exponential backoff.

SpaceZero webhook is idempotent (safe to process multiple times).

CORS

Same-origin requests: No CORS headers needed.

Cross-origin: Not supported for security.

Versioning

API version tied to app version in package.json.

Breaking changes only in major versions.

Testing

bash

# Local testing with wrangler

wrangler dev

 

# Test webhook with ngrok

ngrok http 3000

# Configure: https://xxx.ngrok.io/api/webhooks/gumroad

Monitoring

  • Cloudflare Pages Functions logs
  • console.log visible in dashboard
  • Structured logging recommended