BLOGS

4 Ways to Send Emails with Supabase (2026)

March 11, 2026

Four production-ready approaches to sending emails from Supabase: SMTP for auth emails, Edge Functions, database webhooks, auth hooks, and the Node.js SDK.

4 Ways to Send Emails with Supabase (2026)

Motivation

Supabase ships with a built-in email service, but it’s capped at 2 emails per hour and limited to authentication flows. The moment you need a custom welcome email, an order confirmation, or any production-level volume, you need an external provider.

There are four main approaches depending on what you’re sending and how you want to trigger it:

ApproachBest forCode required
SMTP integrationAuth emails (magic links, password resets, confirmations)None
Edge FunctionsCustom emails triggered via REST or frontend callsModerate
Database webhooksAutomatic emails when a row is inserted, updated, or deletedModerate
Auth hooksAutomatic emails on signup, sign-in, or password resetModerate

If you already run a Node.js backend, you can also use the Node.js server SDK directly — covered at the end of this guide.

All examples use Pingram as the email provider. Here’s a quick comparison of popular providers:

ProviderFree TierCost per 1K EmailsNotes
Pingram3,000/month$0.40Also supports SMS, Slack, push, and more
Resend3,000/month$0.40Email-focused, requires domain setup
SendGrid3,000 trial (60 days)$0.40Good for enterprise teams
AWS SES3,000/mo (12 mo trial)$0.10Complex setup, verification can take weeks

For a full breakdown, see Best SMTP Providers for Supabase in 2026.

Prerequisites

1. SMTP Integration for Auth Emails

Supabase auth emails (magic links, password resets, email confirmations) are sent through SMTP. By default they use Supabase’s built-in sender with the 2/hour rate limit. Swapping in Pingram’s SMTP credentials removes that limit and gives you delivery tracking, open/click analytics, and branded sender addresses with zero code changes.

Setup

One-Click: look for the Integrate with Supabase button in our dashboard, available in the onboarding or the Settings page.

Manual:

  1. In the Pingram dashboard, copy your SMTP credentials
  2. In the Supabase dashboard, go to Project Settings > Authentication > SMTP Settings
  3. Toggle Enable Custom SMTP and paste the values from Pingram.

That’s it. All Supabase auth emails now route through Pingram. You can monitor delivery, opens, and bounces from the Pingram Logs page without touching any code.

Also don’t forget to verify your domain in Pingram to send from your own domain.

2. Edge Functions for Custom Emails

Edge Functions are Supabase’s serverless runtime. They run on Deno, deploy globally, and can be called from your frontend, from curl, or from any HTTP client. Use them when you need to send emails from application logic but don’t want to maintain a separate server.

Scaffold a new function (in this example, a function that creates orders on a store):

supabase functions new create-order

Replace the contents of supabase/functions/create-order/index.ts:

import { Pingram } from 'npm:pingram';

const pingram = new Pingram({
  apiKey: Deno.env.get('PINGRAM_API_KEY')!
});

Deno.serve(async (req) => {
  if (req.method === 'OPTIONS') {
    return new Response('ok', {
      headers: {
        'Access-Control-Allow-Origin': '*',
        'Access-Control-Allow-Headers':
          'authorization, x-client-info, apikey, content-type'
      }
    });
  }

  await pingram.send({
    type: 'new_order',
    to: { id: 'user_123', email: 'user@example.com' },
    email: {
      subject: 'You have a new order!',
      html: '<h1>You have a new order!</h1>'
    }
  });

  return new Response(JSON.stringify({ success: true }), {
    headers: { 'Content-Type': 'application/json' }
  });
});

The pingram package works in Deno via the npm: specifier — no node_modules needed. The email field lets you set the subject and HTML body inline. You can also use the Pingram dashboard to design templates visually and reference them by type instead.

Call from your frontend

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

const supabase = createClient(
  'https://your-project.supabase.co',
  'your-anon-key'
);

const { data, error } = await supabase.functions.invoke('create-order', {
  body: {
    order: 'order_123'
  }
});

Store your secrets

For local development, create supabase/functions/.env:

PINGRAM_API_KEY=pingram_sk_...

For production:

supabase secrets set PINGRAM_API_KEY=pingram_sk_...

Test locally

supabase start
supabase functions serve create-order --no-verify-jwt --env-file supabase/functions/.env
curl -i --request POST 'http://localhost:54321/functions/v1/send-email'

Deploy

supabase functions deploy create-order

For a deeper walkthrough including CORS handling and advanced patterns, see Send Email from Supabase Edge Functions (Deno, 2026).

3. Database Webhooks

Supabase Database Webhooks call an Edge Function automatically whenever a row is inserted, updated, or deleted. No polling, no cron jobs — the email fires the moment data changes.

Create an Edge Function that parses the webhook payload:

supabase functions new order-notification
import { Pingram } from 'npm:pingram';
import { createClient } from 'npm:@supabase/supabase-js';

const pingram = new Pingram({
  apiKey: Deno.env.get('PINGRAM_API_KEY')!
});

const supabase = createClient(
  Deno.env.get('SUPABASE_URL')!,
  Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
);

Deno.serve(async (req) => {
  const payload = await req.json();
  const order = payload.record;

  const { data: user } = await supabase
    .from('profiles')
    .select('email, first_name')
    .eq('id', order.user_id)
    .single();

  if (!user) {
    return new Response(JSON.stringify({ error: 'User not found' }), {
      status: 404
    });
  }

  await pingram.send({
    type: 'order_confirmation',
    to: { id: order.user_id, email: user.email },
    email: {
      subject: `Order #${order.id} confirmed`,
      html: `<h1>Thanks, ${user.first_name}!</h1><p>Your order total: $${order.total}</p>`
    }
  });

  return new Response(JSON.stringify({ success: true }), {
    headers: { 'Content-Type': 'application/json' }
  });
});

The key difference from a regular Edge Function: Supabase sends the webhook payload with a record field containing the row that changed. Your function typically needs to look up related data (like the user’s email) from another table.

Wire the webhook

In the Supabase Dashboard:

  1. Go to Database > Webhooks
  2. Click Create a new webhook
  3. Select the orders table and the INSERT event
  4. Set the type to Supabase Edge Function and choose order-notification

Deploy the function and every new row in the orders table triggers an email automatically.

4. Auth Hooks

Supabase Auth Hooks run an Edge Function when a user signs up, signs in, or resets their password. Use them for branded welcome emails, onboarding sequences, or any logic that should fire on auth events.

supabase functions new auth-welcome
import { Pingram } from 'npm:pingram';

const pingram = new Pingram({
  apiKey: Deno.env.get('PINGRAM_API_KEY')!
});

Deno.serve(async (req) => {
  const payload = await req.json();
  const { user } = payload;

  const firstName = user.user_metadata?.first_name ?? 'there';

  await pingram.send({
    type: 'welcome',
    to: { id: user.id, email: user.email },
    email: {
      subject: 'Welcome!',
      html: `<h1>Hey ${firstName}, welcome aboard!</h1>`
    }
  });

  return new Response(JSON.stringify({ success: true }), {
    headers: { 'Content-Type': 'application/json' }
  });
});

The payload shape is different from database webhooks — it contains a user object from the auth event instead of a table row.

Enable the hook

In the Supabase Dashboard, go to Authentication > Hooks and point it at your auth-welcome function. Deploy with:

supabase functions deploy auth-welcome

Alternative: Node.js Server SDK

If you already run a Node.js backend (Express, Fastify, Next.js API routes), you can send emails directly from your server without Edge Functions.

Install

npm install pingram

Node Example

const { Pingram } = require('pingram');

const pingram = new Pingram({
  apiKey: process.env.PINGRAM_API_KEY
});

await pingram.send({
  type: 'welcome',
  to: { id: 'user_123', email: 'user@example.com' },
  email: {
    subject: 'Welcome!',
    html: '<h1>Welcome to our app!</h1>'
  }
});

You can also listen for Supabase real-time changes from Node.js using the Supabase client library:

const { createClient } = require('@supabase/supabase-js');
const { Pingram } = require('pingram');

const supabase = createClient(
  process.env.SUPABASE_URL,
  process.env.SUPABASE_SERVICE_ROLE_KEY
);

const pingram = new Pingram({
  apiKey: process.env.PINGRAM_API_KEY
});

supabase
  .channel('orders')
  .on(
    'postgres_changes',
    { event: 'INSERT', schema: 'public', table: 'orders' },
    async (payload) => {
      const order = payload.new;

      const { data: user } = await supabase
        .from('profiles')
        .select('email, first_name')
        .eq('id', order.user_id)
        .single();

      if (user) {
        await pingram.send({
          type: 'order_confirmation',
          to: { id: order.user_id, email: user.email },
          email: {
            subject: `Order #${order.id} confirmed`,
            html: `<h1>Thanks, ${user.first_name}!</h1><p>Your order total: $${order.total}</p>`
          }
        });
      }
    }
  )
  .subscribe();

Which Approach Should You Use?

You can combine multiple approaches in the same project — SMTP for auth emails, database webhooks for order notifications, and Edge Functions for everything else.

Common Issues and Fixes

Supabase auth emails coming from a Pingram domain

By default, emails are sent from a Pingram domain. To send from your own domain, verify it in the Pingram dashboard under Settings > Domains. This also improves deliverability.

”Module not found” in Edge Functions

Deno’s npm: specifier requires Supabase CLI v1.50+:

npm i -g supabase@latest

Not receiving emails

Check the Pingram Logs page. If the email appears in logs but isn’t arriving, look at the log detals.

If the email doesn’t appear in logs at all, your API key or region is likely misconfigured. Make sure you’re initializing with the correct region:

const pingram = new Pingram({
  apiKey: 'pingram_sk_...',
  region: 'us' // or 'eu', 'ca' (US is default and not required)
});

Resources