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
- Function source:
supabase/functions/approve-waitlist/index.ts - Migration that creates the
user_profilestarget:supabase/migrations/0009_user_profiles.sql
1. Prerequisites
-
Supabase CLI installed and linked to the project.
Terminal window npm install -g supabasesupabase loginsupabase 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.sqlin 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 = falseDeploy from the repo root:
supabase functions deploy approve-waitlistThe CLI reads config.toml and applies verify_jwt = false at deploy time.
You can also explicitly flag it on a one-off deploy:
supabase functions deploy approve-waitlist --no-verify-jwtExpected output ends with Deployed Function: approve-waitlist.
The function’s invocation URL is:
https://<project-ref>.functions.supabase.co/approve-waitlistReference: Deploying Edge Functions
Reference: config.toml functions section
3. Set secrets
supabase secrets set RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxxxxxxsupabase secrets set WEBHOOK_SHARED_SECRET=$(openssl rand -hex 32)Optional overrides (both have sensible defaults in the function):
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:
| Field | Value |
|---|---|
| Name | waitlist_approval_notify |
| Table | waitlist_requests |
| Events | Update |
| Type | HTTP Request |
| HTTP Method | POST |
| URL | https://<project-ref>.functions.supabase.co/approve-waitlist |
| HTTP Headers | Content-Type: application/jsonX-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 control — example.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_requestsSET status = 'approved'WHERE email = '<your-real-email@example>';Expected results:
-
Within ~5 seconds, an email arrives at
test@example.com. -
user_profilesdoes not yet have a row for this email (the user hasn’t signed up, souser_idis NULL and the Edge Function skipped the upsert). -
After the user signs in with Google/Apple using
test@example.com, thehandle_new_auth_usertrigger (migration 0020, SUR-362) inserts theuser_profilesrow with default quota (month_limit=50,user_tier='free'). Note: post-SUR-362,waitlist_requests.user_idis 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_requestsSET 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 fragment | Meaning |
|---|---|
RESEND_API_KEY not set — skipping email send | Set the secret, redeploy is not required. |
Resend returned non-2xx: 401 | RESEND_API_KEY is wrong or revoked. |
Resend returned non-2xx: 403 | Sender domain (hello@surfc.app) not verified in Resend dashboard. |
auth.admin.getUserById failed | Service-role key missing permissions — should never happen in prod. |
ai_usage_daily query failed | Non-fatal; profile still upserted with month_usage = 0. |
Response body skipped: not_an_approval_transition | Webhook 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 arrives | Check 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:
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
wasApprovedguard). The webhook’s retry behaviour is safe. - Email failures do not block DB writes. If Resend is down,
notifiedwill befalsebutuser_profilesis 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.