Email providers (Resend, Postmark, SendGrid)

Send your app's own emails from SupaHost with Resend, Postmark or SendGrid through an Edge Function, trigger them from the database, and understand how this differs from Auth emails.

There are two kinds of email in a Supabase app, and on SupaHost they're handled differently:

Kind Examples Who sends it
Auth emails Confirm sign-up, magic link, password reset, invite, change email SupaHost, from [email protected] with your App name as the sender name
App emails Booking confirmations, receipts, reminders, digests You, through a provider such as Resend, Postmark or SendGrid

Custom SMTP for Auth emails isn't available on SupaHost, so you can't route Auth emails through your own provider account. You can change their wording on the project's Auth tab, in the Auth emails card; see Auth emails and templates. Everything else your app sends goes through your provider, usually from an Edge Function.

Why an Edge Function

Provider API keys are secrets. They can't be in browser or mobile code, so the sending happens on a server. An Edge Function is the closest server you have: it runs on your instance, can read the provider key from function secrets, and can check who is asking.

1. Add the provider key

On the project's Functions tab, in the Secrets card, add your provider's key, for example RESEND_API_KEY, POSTMARK_SERVER_TOKEN or SENDGRID_API_KEY. Verify your sending domain with the provider first, or messages will be rejected or land in spam.

2. Write the function

This function sends a booking confirmation for the signed-in user. Keep Require JWT on.

// send-booking-email/index.ts
import { createClient } from 'npm:@supabase/supabase-js@2'

Deno.serve(async (req) => {
  const supabase = createClient(
    Deno.env.get('SUPABASE_URL')!,
    Deno.env.get('SUPABASE_ANON_KEY')!,
    { global: { headers: { Authorization: req.headers.get('Authorization')! } } }
  )

  const token = (req.headers.get('Authorization') ?? '').replace('Bearer ', '')
  const { data: { user } } = await supabase.auth.getUser(token)
  if (!user?.email) return new Response('Unauthorized', { status: 401 })

  const { bookingId } = await req.json()

  // Runs as the user, so RLS stops them reading someone else's booking.
  const { data: booking, error } = await supabase
    .from('bookings')
    .select('id, room, starts_at, nights')
    .eq('id', bookingId)
    .single()
  if (error) return new Response('Booking not found', { status: 404 })

  const res = await fetch('https://api.resend.com/emails', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${Deno.env.get('RESEND_API_KEY')}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      from: 'Harbour Inn <[email protected]>',
      to: [user.email],
      subject: `Your booking for room ${booking.room}`,
      html: `<p>See you on ${new Date(booking.starts_at).toDateString()} for ${booking.nights} nights.</p>`,
    }),
  })

  if (!res.ok) return new Response(await res.text(), { status: 502 })
  return Response.json({ sent: true })
})

Deploy it from the Functions tab and call it from your app:

await supabase.functions.invoke('send-booking-email', { body: { bookingId: 42 } })

Other providers

Only the fetch call changes. Postmark:

await fetch('https://api.postmarkapp.com/email', {
  method: 'POST',
  headers: {
    'X-Postmark-Server-Token': Deno.env.get('POSTMARK_SERVER_TOKEN')!,
    'Content-Type': 'application/json',
    Accept: 'application/json',
  },
  body: JSON.stringify({
    From: '[email protected]',
    To: user.email,
    Subject: 'Your booking',
    HtmlBody: '<p>Thanks for booking.</p>',
    MessageStream: 'outbound',
  }),
})

SendGrid:

await fetch('https://api.sendgrid.com/v3/mail/send', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${Deno.env.get('SENDGRID_API_KEY')}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    personalizations: [{ to: [{ email: user.email }] }],
    from: { email: '[email protected]' },
    subject: 'Your booking',
    content: [{ type: 'text/html', value: '<p>Thanks for booking.</p>' }],
  }),
})

Each provider also has an npm SDK you can import with npm:; plain fetch keeps the function small.

Sending when data changes

To email when a row is inserted, without the app calling anything, let the database call your function with pg_net, which is installed in every SupaHost project. Turn Require JWT off for that function and check a shared secret instead:

create or replace function public.email_new_booking()
returns trigger
language plpgsql
security definer
set search_path = ''
as $$
begin
  perform net.http_post(
    url := 'https://app-one.acme.supahost.space/functions/v1/booking-created',
    body := jsonb_build_object('booking_id', new.id),
    headers := jsonb_build_object(
      'Content-Type', 'application/json',
      'x-webhook-secret', (select decrypted_secret from vault.decrypted_secrets where name = 'booking_webhook_secret')
    )
  );
  return new;
end;
$$;

create trigger bookings_send_email
  after insert on public.bookings
  for each row execute function public.email_new_booking();

Store the secret once with select vault.create_secret('<random value>', 'booking_webhook_secret');, and add the same value as a function secret so the function can compare it. pg_net sends the request in the background after the transaction commits, so a slow provider doesn't slow down the insert. Inside the function, use the service_role key to look up the booking and the guest's email.

For reminders and digests, schedule a function call with pg_cron instead.

Updated 11 November 2026 · View as Markdown · Something wrong or missing? Tell us