Supabase Auth, database webhooks, and Edge Functions often need transactional email: welcome messages, verification links, receipts. You can point Supabase’s built-in SMTP at a provider — or call a thin HTTP API yourself when you want full control of the HTML.
This guide sends email from a Supabase Edge Function using Notify: POST https://notify.cx/api/email/send, header x-api-key, body to / from / subject / message. Store the key as an Edge secret — never in client code or the mobile app.
Official stack guide: Notify + Supabase.
Prerequisites
- Supabase project + Supabase CLI
- Deno-compatible Edge Functions enabled on the project
- Notify API key
- Verified domain for production
from(domain verification)
Notify plans: Free 1,000 emails/mo, Pro $10 / 10,000, Scale $50 / 100,000 — pricing.
# From your repo root
npx supabase login
npx supabase link --project-ref YOUR_PROJECT_REF
npx supabase secrets set NOTIFY_API_KEY=your_api_key_here
Until SPF/DKIM are ready, rehearse with https://notify.cx/api/email/send/test (sandbox vs production).
Scaffold the function
npx supabase functions new send-welcome
Complete Edge Function
supabase/functions/send-welcome/index.ts:
import { serve } from 'https://deno.land/std@0.224.0/http/server.ts';
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.45.0';
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers':
'authorization, x-client-info, apikey, content-type'
};
type WelcomeBody = {
to?: string;
name?: string;
};
serve(async (req) => {
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders });
}
try {
const supabaseUrl = Deno.env.get('SUPABASE_URL') ?? '';
const supabaseAnon = Deno.env.get('SUPABASE_ANON_KEY') ?? '';
const notifyKey = Deno.env.get('NOTIFY_API_KEY') ?? '';
if (!notifyKey) {
return json({ error: 'NOTIFY_API_KEY not configured' }, 500);
}
// Require a valid user JWT (or call this only from a service-role webhook).
const authHeader = req.headers.get('Authorization');
if (!authHeader) {
return json({ error: 'Missing Authorization' }, 401);
}
const supabase = createClient(supabaseUrl, supabaseAnon, {
global: { headers: { Authorization: authHeader } }
});
const {
data: { user },
error: userError
} = await supabase.auth.getUser();
if (userError || !user?.email) {
return json({ error: 'Unauthorized' }, 401);
}
const body = (await req.json()) as WelcomeBody;
const to = (body.to ?? user.email).trim();
const name = body.name?.trim() ?? '';
// Only allow emailing the authenticated user (prevents open relay).
if (to.toLowerCase() !== user.email.toLowerCase()) {
return json({ error: 'Forbidden recipient' }, 403);
}
const notifyRes = await fetch('https://notify.cx/api/email/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': notifyKey
},
body: JSON.stringify({
from: 'noreply@your-verified-domain.com',
to,
subject: 'Welcome',
message: `
<h1>Welcome${name ? `, ${name}` : ''}</h1>
<p>Thanks for joining.</p>
<p><a href="https://yourapp.com/dashboard">Open your dashboard</a></p>
`
})
});
const text = await notifyRes.text();
return new Response(text, {
status: notifyRes.status,
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
});
} catch (err) {
return json({ error: String(err) }, 500);
}
});
function json(payload: unknown, status = 200) {
return new Response(JSON.stringify(payload), {
status,
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
});
}
Deploy:
npx supabase functions deploy send-welcome
Invoke (after the user has a session):
curl -X POST \
"https://YOUR_PROJECT_REF.supabase.co/functions/v1/send-welcome" \
-H "Authorization: Bearer USER_JWT" \
-H "Content-Type: application/json" \
-d '{"name":"Ada"}'
Database webhook pattern (service role)
When a profiles row is inserted, you may prefer a webhook that uses the service role key instead of a user JWT. In that case:
- Generate tokens / links in SQL or another function first
- Call Notify with finished HTML only
- Gate with an idempotency column (
welcome_email_sent_at) so retries do not spam
Keep token generation in your DB — Notify is delivery, not your auth store. Patterns: magic link & OTP, auth verification recipe.
Auth email note
Supabase Auth can send its own mail via SMTP settings. If you want Notify for branded HTML you control, send from an Edge Function or your Next.js backend after signup — do not try to bolt a marketing ESP onto transactional resets.
Local serve
npx supabase functions serve send-welcome --env-file ./supabase/.env.local
Put NOTIFY_API_KEY in that env file for local only. Hit the local URL with a real user JWT from supabase.auth.signInWithPassword (or the dashboard). Confirm Notify returns 2xx before deploying.
For HTML iteration without delivery, temporarily point fetch at https://notify.cx/api/email/send/test — same body, non-delivering. Details: sandbox vs production.
When to prefer your Next.js backend instead
Edge Functions are great when the trigger already lives in Supabase (auth hook, DB webhook). If your app is primarily Next.js App Router, a Server Action or Route Handler may be simpler — same Notify helper, fewer Deno-specific pieces. See Next.js + Notify.
Either way: tokens in your tables, HTML you own, Notify for delivery only. Product home and signup: notify.cx.
Common pitfalls
- Exposing a function that accepts arbitrary
to/ HTML without verifying the caller - Putting
NOTIFY_API_KEYin the browser or Flutter/React Native bundle - Forgetting CORS preflight handling for browser invokes
- Using production
frombefore domain verification - Treating welcome + drip as one pipeline on the transactional domain
- Calling Notify from a database trigger with no idempotency column
Next steps
- Verify your domain and switch off any sandbox URL
- Wire password reset / magic link HTML the same way (password resets)
- Monitor delivery in Notify logs; add webhooks on Pro/Scale
- Read the quick start for the core API
- Keep marketing sends off this domain’s reputation
Resources
Further Reading
Discover more articles on similar topics across our network
Comments
Loading comments…