Skip to content

Configuring the approve-waitlist Edge Function

Configuring the approve-waitlist Edge Function

SUR-186

The approve-waitlist Edge Function fires when a row in waitlist_requests is updated to status='approved'. It sends the user a “you’re in” email via Resend and — if the user has already signed up — upserts their user_profiles row with an initial month_usage snapshot.

This guide walks through deploying the function, setting its secrets, and wiring the Database Webhook that invokes it.

Related files


1. Prerequisites

  • Supabase CLI installed and linked to the project.

    Terminal window
    npm install -g supabase
    supabase login
    supabase link --project-ref <your-project-ref>

    Reference: Supabase CLI install

  • Resend account with an API key. Free tier covers the waitlist scale. Reference: Resend → Send your first email

  • Migration 0009 applied. Run the contents of supabase/migrations/0009_user_profiles.sql in the Supabase SQL editor. Confirm with the runbook queries at the bottom of the migration file.


2. Deploy the function

Important: this function is invoked by a Database Webhook, which cannot send a Supabase JWT. The Edge Runtime gateway’s default JWT check must be disabled for approve-waitlist — otherwise the gateway returns 401 UNAUTHORIZED_NO_AUTH_HEADER before the function runs, and you get “no function logs, no email, no retries reaching the function.” The function authenticates the caller via its own X-Webhook-Secret header check.

This is already persisted in supabase/config.toml:

[functions.approve-waitlist]
verify_jwt = false

Deploy from the repo root:

Terminal window
supabase functions deploy approve-waitlist

The CLI reads config.toml and applies verify_jwt = false at deploy time. You can also explicitly flag it on a one-off deploy:

Terminal window
supabase functions deploy approve-waitlist --no-verify-jwt

Expected output ends with Deployed Function: approve-waitlist.

The function’s invocation URL is:

https://<project-ref>.functions.supabase.co/approve-waitlist

Reference: Deploying Edge Functions Reference: config.toml functions section


3. Set secrets

Terminal window
supabase secrets set RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxxxxxx
supabase secrets set WEBHOOK_SHARED_SECRET=$(openssl rand -hex 32)

Optional overrides (both have sensible defaults in the function):

Terminal window
supabase secrets set APPROVAL_EMAIL_FROM="Surfc <hello@surfc.app>"
supabase secrets set APP_URL="https://app.surfc.app"

You do not need to set SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY. The Supabase Functions runtime injects both on every invocation; the injected values always take precedence, and the other Edge Functions in this project (anthropic-proxy, delete-account) rely on exactly that behaviour.

If these names already exist as secrets in your project (e.g. set by an older Supabase CLI before the platform started blocking the SUPABASE_ prefix), leave them in place — removing them is unnecessary and could break any workflow that reads from the dashboard secret listing.

Save the WEBHOOK_SHARED_SECRET value — you will paste it into the webhook header configuration in the next step.

Reference: Edge Function secrets


4. Create the Database Webhook

Dashboard → Database → Webhooks → Create a new hook:

FieldValue
Namewaitlist_approval_notify
Tablewaitlist_requests
EventsUpdate
TypeHTTP Request
HTTP MethodPOST
URLhttps://<project-ref>.functions.supabase.co/approve-waitlist
HTTP HeadersContent-Type: application/json
X-Webhook-Secret: <your-shared-secret>

The function itself filters out non-approval transitions (see the wasApproved / isApproved guard in index.ts), so the webhook can fire on any waitlist_requests UPDATE without producing duplicate notifications.

Reference: Database Webhooks


5. End-to-end smoke test

Run these in the Supabase SQL editor. Use an email address at a real domain that you controlexample.com, test.com, and similar RFC 2606 reserved domains are undeliverable by design, so the API will return notified: true but the email will bounce silently at the recipient MX. Replace <your-real-email@example> below with an inbox you can actually check.

-- 5a. Create a pending request.
INSERT INTO waitlist_requests (email, name, status)
VALUES ('<your-real-email@example>', 'Test User', 'pending');
-- 5b. Flip to approved. This triggers the webhook.
UPDATE waitlist_requests
SET status = 'approved'
WHERE email = '<your-real-email@example>';

Expected results:

  1. Within ~5 seconds, an email arrives at test@example.com.

  2. user_profiles does not yet have a row for this email (the user hasn’t signed up, so user_id is NULL and the Edge Function skipped the upsert).

  3. After the user signs in with Google/Apple using test@example.com, the handle_new_auth_user trigger (migration 0020, SUR-362) inserts the user_profiles row with default quota (month_limit=50, user_tier='free'). Note: post-SUR-362, waitlist_requests.user_id is not auto-back-linked — that side-effect was removed when the waitlist gate was lifted (SUR-358 family). If you need the back-link for legacy reporting, run it manually:

    UPDATE waitlist_requests SET user_id = (
    SELECT id FROM auth.users WHERE email = waitlist_requests.email
    ) WHERE email = 'test@example.com' AND user_id IS NULL;

    Verify the profile row:

    SELECT * FROM user_profiles WHERE email = 'test@example.com';

To exercise the approval-after-signup path:

-- 5c. Flip an already-signed-up user from pending to approved.
UPDATE waitlist_requests
SET status = 'approved'
WHERE email = 'alreadysignedup@example.com';

Expected: email arrives; user_profiles is upserted with month_usage populated from ai_usage_daily for the current UTC month.


6. Logs and troubleshooting

Function logs Dashboard → Edge Functions → approve-waitlist → Logs Reference: Edge Function logs

Common log lines from approve-waitlist:

Log fragmentMeaning
RESEND_API_KEY not set — skipping email sendSet the secret, redeploy is not required.
Resend returned non-2xx: 401RESEND_API_KEY is wrong or revoked.
Resend returned non-2xx: 403Sender domain (hello@surfc.app) not verified in Resend dashboard.
auth.admin.getUserById failedService-role key missing permissions — should never happen in prod.
ai_usage_daily query failedNon-fatal; profile still upserted with month_usage = 0.
Response body skipped: not_an_approval_transitionWebhook fired on a non-approval UPDATE — ignored, as intended.
UNAUTHORIZED_NO_AUTH_HEADER (no function logs)JWT gate is on — redeploy with verify_jwt = false (see step 2).
notified: true but no email arrivesCheck Resend → Emails for the delivery status. Common causes: recipient is an RFC 2606 reserved domain (example.com), sender domain (surfc.app) not verified, or message landed in spam.

Webhook delivery history Dashboard → Database → Webhooks → click the hook → Recent deliveries. A 401 response means the X-Webhook-Secret header doesn’t match WEBHOOK_SHARED_SECRET — regenerate and reconfigure both sides.

Authentication of the webhook The webhook header’s value must exactly match the value stored in WEBHOOK_SHARED_SECRET. To verify:

Terminal window
curl -X POST https://<project-ref>.functions.supabase.co/approve-waitlist \
-H 'X-Webhook-Secret: <value>' \
-H 'Content-Type: application/json' \
-d '{"record":{"email":"x@x","name":"x","role":"user","status":"approved","user_id":null},"old_record":{"status":"pending"}}'

Expected: {"ok":true,"notified":true,"profile_upserted":false,"user_id_present":false} (or notified:false if Resend is misconfigured).


7. Operational notes

  • Idempotency. Flipping the same row from approved → approved is a no-op in the function (the wasApproved guard). The webhook’s retry behaviour is safe.
  • Email failures do not block DB writes. If Resend is down, notified will be false but user_profiles is still upserted when the user_id is present. The webhook retries, which re-attempts the email delivery.
  • Rotating the shared secret. Run supabase secrets set WEBHOOK_SHARED_SECRET=$(openssl rand -hex 32), then update the webhook header in the dashboard. Until both match, the function will return 401.
  • Rotating the Resend key. Set the new key in Resend, update the secret via the Supabase CLI; no redeploy required.