What it does

Supabase gives you Postgres, Auth, and Edge Functions. It does not give you a production sender — the built-in Auth mailer is rate-limited, sends from Supabase's own domain, and gives you no delivery, bounce, or complaint data. Volanea is the sending layer underneath it.

There are two integration points, and most projects use both:

What it coversStatus
Auth emailsConfirmation, magic link, password reset, invite, email changeSend Email Hook → Edge Function works today. Custom SMTP is coming soon.
App emailsReceipts, notifications, digests — anything triggered by your own dataWorks today

Both send from a domain you have verified, and both stream email.delivered, email.bounced, and email.complained events back to your app.

Before you start

1. Verify a sending domain. In the Volanea dashboard, go to Domains → Add domain and enter the domain you want to send from (yourapp.com, or a subdomain like mail.yourapp.com). Volanea shows you the DKIM, SPF, and DMARC records to add — copy each one into your DNS provider exactly as shown, host and value.

Verification usually completes within 15 minutes of the records propagating; the domain flips to verified on its own. Until it does, POST /v1/send returns 403 send_failed for any From address on that domain, so do this first.

Two DNS traps worth knowing before you paste:

  • If your DNS already has a wildcard record like *.yourapp.com, it will shadow the _domainkey lookups. Add the DKIM hosts explicitly.
  • You get one SPF record per domain. If you already have one, merge Volanea's include: into it rather than adding a second TXT record — two SPF records is a permanent failure, not a warning.

2. Get your keys. In the dashboard, Settings → API keys. Click a key to reveal it.

  • Secret key (sk_…) — full API access. This is your SMTP password and your Authorization: Bearer token.
  • Test key (vk_test_… / test mode) — runs the entire pipeline, renders the template, logs the message, and delivers nothing. Use it while you wire up Edge Functions so you are not mailing real users during development.

Never put either key in a Supabase client-side environment variable. Both belong in Edge Function secrets or the Auth SMTP settings, which are server-side only.

Auth emails, path A — Send Email Hook (works today)

Supabase's Send Email Hook hands you every Auth email before it is sent and lets you deliver it yourself. This is the recommended path: you get your own templates, your own domain, and the full Volanea event stream, without waiting on SMTP.

1. Write the Edge Function

supabase functions new auth-email
// supabase/functions/auth-email/index.ts
import { Webhook } from "https://esm.sh/standardwebhooks@1.0.0";

const HOOK_SECRET = Deno.env.get("SEND_EMAIL_HOOK_SECRET")!; // "v1,whsec_…"
const VOLANEA_KEY = Deno.env.get("VOLANEA_SECRET_KEY")!;
const FROM = "auth@yourapp.com"; // must be on your verified domain

type Payload = {
  user: { email: string; id: string };
  email_data: {
    token: string;
    token_hash: string;
    redirect_to: string;
    email_action_type: string; // "signup" | "recovery" | "magiclink" | "invite" | "email_change"
    site_url: string;
  };
};

const SUBJECTS: Record<string, string> = {
  signup: "Confirm your email",
  magiclink: "Your sign-in link",
  recovery: "Reset your password",
  invite: "You've been invited",
  email_change: "Confirm your new email",
};

Deno.serve(async (req) => {
  const body = await req.text();

  // Supabase signs the hook with Standard Webhooks. Verify before trusting it —
  // this endpoint is public, and the payload contains a valid auth token.
  const wh = new Webhook(HOOK_SECRET.replace("v1,whsec_", ""));
  let payload: Payload;
  try {
    payload = wh.verify(body, Object.fromEntries(req.headers)) as Payload;
  } catch {
    return new Response("invalid signature", { status: 401 });
  }

  const { user, email_data } = payload;
  const action = email_data.email_action_type;

  // Supabase gives you the token, not the link — you assemble the verify URL.
  const url = new URL("/auth/v1/verify", email_data.site_url);
  url.searchParams.set("token", email_data.token_hash);
  url.searchParams.set("type", action);
  url.searchParams.set("redirect_to", email_data.redirect_to);

  const res = await fetch("https://api.volanea.com/v1/send", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${VOLANEA_KEY}`,
      "Content-Type": "application/json",
      // Auth retries on failure; the key makes a retry a no-op instead of a second email.
      "Idempotency-Key": `auth-${action}-${email_data.token_hash}`,
    },
    body: JSON.stringify({
      to: user.email,
      from: FROM,
      subject: SUBJECTS[action] ?? "Action required",
      type: "transactional",
      html: `<p>Click to continue:</p><p><a href="${url}">${SUBJECTS[action] ?? "Continue"}</a></p>
             <p>Or use this code: <strong>${email_data.token}</strong></p>
             <p>This link expires in one hour. If you didn't request it, ignore this email.</p>`,
    }),
  });

  if (!res.ok) {
    // Returning non-2xx tells Supabase the send failed, and it surfaces the
    // error to the caller instead of pretending the email went out.
    return new Response(await res.text(), { status: 500 });
  }
  return new Response("{}", { headers: { "Content-Type": "application/json" } });
});

2. Deploy it and set the secrets

The hook is called by Supabase Auth, not by a logged-in user, so it must not require a JWT:

supabase secrets set VOLANEA_SECRET_KEY=sk_your_key
supabase functions deploy auth-email --no-verify-jwt

If you would rather not use the CLI, the same secrets live in the Supabase Dashboard under Edge Functions → Secrets — they are stored per project, separately from anything in your .env.

3. Point Auth at it

Supabase Dashboard → Authentication → HooksSend Email HookEnable. Choose HTTPS and enter your function URL:

https://<project-ref>.supabase.co/functions/v1/auth-email

Supabase generates the signing secret on that screen. Copy it and set it on the function:

supabase secrets set SEND_EMAIL_HOOK_SECRET='v1,whsec_...'

Trigger a password reset from your app. The message should appear in the Volanea dashboard under Emails within a second or two.

Auth emails, path B — custom SMTP

Coming soon. The public SMTP relay is not open yet. Everything below is the configuration it will take; until it opens, use the Send Email Hook above — it is the better integration anyway, because it gives you delivery events per Auth email.

Supabase Dashboard → Project Settings → Authentication → SMTP Settings (on newer projects the same panel is at Authentication → Emails → SMTP Settings). Toggle Enable Custom SMTP and fill in:

FieldValue
Sender emailAn address on your verified domain, e.g. auth@yourapp.com
Sender nameYour product name
Hostsmtp.volanea.com
Port2525
Usernamevolanea (any value is accepted — the username is ignored)
PasswordYour Volanea secret key, sk_…

On the port. Supabase pre-fills 587, which you will want to change. Volanea's relay listens on 2525 — the standard alternate submission port — because AWS and most other clouds block outbound 25, and 2525 is the port that is reachable from managed environments without an exception request. Supabase's port field accepts any value; just replace the default.

The sender email must be on a domain that is already verified in Volanea. If it is not, Supabase's own "Send test email" button will fail with a 550 from the relay rather than a config error, which is easy to misread.

Also raise Rate limit for sending emails under Authentication → Rate Limits — it defaults low to protect Supabase's shared sender, and that ceiling no longer applies once you are on your own infrastructure.

App emails — Edge Function calling the Volanea API

Anything triggered by your own logic — a receipt, a welcome email, a weekly digest — is a plain fetch from an Edge Function. No SDK, no npm dependency.

// supabase/functions/send-receipt/index.ts
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";

const VOLANEA_KEY = Deno.env.get("VOLANEA_SECRET_KEY")!;

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

  // Service-role client: this runs server-side, so it bypasses RLS.
  const db = createClient(
    Deno.env.get("SUPABASE_URL")!,
    Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
  );

  const { data: order, error } = await db
    .from("orders")
    .select("id, total_cents, customer_email, customer_name")
    .eq("id", orderId)
    .single();
  if (error) return new Response(error.message, { status: 404 });

  const res = await fetch("https://api.volanea.com/v1/send", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${VOLANEA_KEY}`,
      "Content-Type": "application/json",
      "Idempotency-Key": `receipt-${order.id}`,
    },
    body: JSON.stringify({
      to: { email: order.customer_email, name: order.customer_name },
      from: "receipts@yourapp.com",
      subject: "Your receipt — order {{orderId}}",
      templateId: "tpl_your_receipt_template", // or inline `html`
      type: "transactional",
      variables: {
        orderId: order.id,
        firstName: order.customer_name?.split(" ")[0] ?? "there",
        total: `$${(order.total_cents / 100).toFixed(2)}`,
      },
    }),
  });

  const body = await res.json();
  if (!res.ok) {
    console.error("volanea send failed", body);
    return new Response(JSON.stringify(body), { status: 502 });
  }

  // { success: true, data: { messages: [{ id, to, status: "queued" }], testMode: false } }
  const emailId = body.data.messages[0].id;
  await db.from("orders").update({ receipt_email_id: emailId }).eq("id", order.id);

  return new Response(JSON.stringify({ emailId }), {
    headers: { "Content-Type": "application/json" },
  });
});
supabase secrets set VOLANEA_SECRET_KEY=sk_your_key
supabase functions deploy send-receipt

SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY are injected into every Edge Function automatically — you do not set those yourself.

Storing the returned emailId on the row is what makes the delivery webhook useful later: it is the join key between a Volanea event and the order it belongs to.

A note on type

Inline html defaults to transactional, which skips the unsubscribe footer and ignores unsubscribe state. That is correct for a receipt and wrong for a digest. Anything promotional must set "type": "marketing" explicitly, or it will be delivered to people who have unsubscribed.

Database webhook → Edge Function → email

To send on a row change instead of from application code, wire a Postgres trigger to the function.

Using the dashboard

Supabase Dashboard → Database → WebhooksCreate a new hook:

  • Tableorders
  • EventsINSERT
  • TypeSupabase Edge Functions, then pick send-receipt
  • HTTP Headers — add Authorization: Bearer <your anon key> (the dashboard offers to fill this in)

Using SQL

Same thing, if you would rather it live in a migration:

create trigger on_order_created
  after insert on public.orders
  for each row
  execute function supabase_functions.http_request(
    'https://<project-ref>.supabase.co/functions/v1/send-receipt',
    'POST',
    '{"Content-Type":"application/json","Authorization":"Bearer <anon-key>"}',
    '{}',
    '5000'
  );

The function receives the standard database-webhook envelope, not your own shape:

{
  "type": "INSERT",
  "table": "orders",
  "schema": "public",
  "record": { "id": "ord_123", "customer_email": "ada@example.com", "total_cents": 4900 },
  "old_record": null
}

So read record rather than re-querying, and drop the orderId parse from the example above:

const { type, record, old_record } = await req.json();
if (type !== "INSERT") return new Response("ignored", { status: 200 });
// record.customer_email, record.total_cents, …

Trigger emails are fire-and-forget. pg_net dispatches the request asynchronously and does not retry on failure, and a non-2xx response does not roll back the insert. For anything you cannot afford to lose, write a row to an email_outbox table in the same transaction and drain it from a scheduled function — the trigger becomes an optimization, not the delivery guarantee.

Getting delivery events back into Postgres

Sends are asynchronous. A 200 from /v1/send means queued, not delivered — the recipient can still bounce, be suppressed, or be over quota. Point a Volanea webhook at another Edge Function to record the outcome.

create table public.email_events (
  id            bigint generated always as identity primary key,
  email_id      text not null,
  event         text not null,
  recipient     text,
  occurred_at   timestamptz not null default now(),
  payload       jsonb
);
create index on public.email_events (email_id);
// supabase/functions/volanea-events/index.ts
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
import { createHmac, timingSafeEqual } from "node:crypto";

const SECRET = Deno.env.get("VOLANEA_WEBHOOK_SECRET")!; // whsec_…

function verify(raw: string, header: string): boolean {
  // X-Volanea-Signature: t=<unix-ms>,v1=<hex hmac-sha256 of "<t>.<raw body>">
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const t = Number(parts.t);
  if (!parts.v1 || !Number.isFinite(t)) return false;
  if (Math.abs(Date.now() - t) > 5 * 60_000) return false; // reject replays
  const expected = createHmac("sha256", SECRET).update(`${t}.${raw}`).digest("hex");
  const a = Buffer.from(expected), b = Buffer.from(parts.v1);
  return a.length === b.length && timingSafeEqual(a, b);
}

Deno.serve(async (req) => {
  const raw = await req.text();
  const sig = req.headers.get("x-volanea-signature") ?? "";
  if (!verify(raw, sig)) return new Response("bad signature", { status: 401 });

  const event = JSON.parse(raw);
  const db = createClient(
    Deno.env.get("SUPABASE_URL")!,
    Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
  );

  await db.from("email_events").insert({
    email_id: event.data?.emailId ?? event.data?.id,
    event: event.type,          // email.delivered, email.bounced, email.complained, …
    recipient: event.data?.to,
    payload: event,
  });

  // A hard bounce or a complaint should stop you ever mailing that address again.
  if (event.type === "email.bounced" || event.type === "email.complained") {
    await db.from("profiles")
      .update({ email_status: event.type === "email.bounced" ? "bounced" : "complained" })
      .eq("email", event.data?.to);
  }

  return new Response("ok");
});
supabase secrets set VOLANEA_WEBHOOK_SECRET=whsec_...
supabase functions deploy volanea-events --no-verify-jwt

Then register it in the Volanea dashboard under Webhooks → New endpoint, subscribing to email.*. Volanea retries with backoff for about 24 hours, so a brief Edge Function outage does not lose events — but do make the insert idempotent if you care about exactly-once, because a retry after a timeout can deliver the same event twice.

--no-verify-jwt is required here: Volanea signs with X-Volanea-Signature, not a Supabase JWT, so leaving JWT verification on rejects every delivery with a 401 before your code runs.

Troubleshooting

403 send_failed — "domain is not verified". The From address is on a domain that has not finished verifying. Check Domains in the dashboard; if DKIM is still pending, confirm the records resolve with dig TXT <selector>._domainkey.yourapp.com and look for a wildcard *.yourapp.com record shadowing them.

Auth emails still come from noreply@mail.app.supabase.io. The Send Email Hook is not enabled, or it is enabled and failing. Supabase falls back to its own mailer when the hook errors. Check the function logs in Edge Functions → auth-email → Logs.

The hook returns 401 "invalid signature". Supabase's secret is stored as v1,whsec_<base64>. The standardwebhooks constructor wants only the part after the prefix — strip v1,whsec_ as the sample does. Also confirm you passed the raw body text to verify(), not a re-serialized object.

The hook returns 401 before your code runs at all. You deployed without --no-verify-jwt. Supabase Auth calls the hook without a user JWT, so the platform rejects it at the edge. Redeploy with the flag.

The Edge Function 404s or times out, and it worked yesterday. Free-tier Supabase projects pause after about a week of inactivity, and a paused project's functions stop answering. Nothing in the function logs explains it, because the function never ran. Restore the project from the Supabase dashboard and retry — this bites most often when you set an integration up, leave it, and come back to test.

Sends succeed but nothing arrives. You are almost certainly using a test key. Test mode runs the whole pipeline and delivers nothing — the response includes "testMode": true, and the message is visible in the dashboard marked as a test. Swap in the live secret key.

429 from /v1/send. Per-project burst cap (30/min on free, 300/min on paid tiers). Retry after the delay in the response, or use POST /v1/send/batch for up to 1,000 personalized messages in one call — a digest job should always batch.

Supabase rate-limits Auth emails. That ceiling is Supabase's, not Volanea's. Authentication → Rate Limits → Rate limit for sending emails. It is set low for the built-in sender and stays low after you switch, so raise it deliberately.

The database trigger fires but no email is sent. Check Database → Webhooks → the hook → Logs for the pg_net response. The usual causes are a missing Authorization header on the trigger (the function returns 401) and a function that expects { orderId } but receives the { type, record } envelope.

Events never reach the webhook function. Deploy it with --no-verify-jwt, then use Send test on the endpoint in the Volanea dashboard. If the test lands and real events do not, check the endpoint's subscribed event patterns — an endpoint scoped to email.sent will not receive email.bounced.

Duplicate emails after a retry. Set Idempotency-Key on every send, derived from something stable about the event — the order id, the Auth token hash. Volanea replays the original response for 24 hours instead of sending again, and marks it with X-Idempotent-Replay: true.