Skip to content

Documentation

Send Supabase Auth emails through Mailtruss.

Hand every auth email to your own Edge Function and send it with Mailtruss.

Before you start

You need a verified sending domain (see Domains) and an API key (under API keys in the dashboard; it is shown once). Keep the key in an environment variable on your server, never in code that runs in a browser or an app.

How it works

Supabase can hand every auth email (sign-up confirmation, password reset, magic link) to your own endpoint through the Send Email hook. This Edge Function follows Supabase's documented payload and signing: it verifies the hook, builds the confirmation link the way their docs show, and sends it. Click tracking is switched off, so auth links are never rewritten.

Secrets

Create the hook secret in the Supabase dashboard (Authentication, Hooks), and set MAILTRUSS_API_KEY, SEND_EMAIL_HOOK_SECRET and SUPABASE_PROJECT_REF as function secrets.

The Edge Function

The code is tested with the documented payload. Deploy it to a test project and confirm one sign-up before relying on it.

supabase/functions/send-email/index.ts
// Supabase Edge Function for Auth > Hooks > Send Email.
// In Deno, import from "npm:mailtruss" and "npm:standardwebhooks".
import { Webhook } from "standardwebhooks";
import { MailClient } from "mailtruss";
​
const mail = new MailClient({ apiKey: process.env.MAILTRUSS_API_KEY! });
// Supabase shows the secret as "v1,whsec_<base64>"; the library wants only the base64 part.
const hook = new Webhook(process.env.SEND_EMAIL_HOOK_SECRET!.replace("v1,whsec_", ""));
​
const SUBJECTS: Record<string, string> = {
signup: "Confirm your email",
recovery: "Reset your password",
magiclink: "Your sign-in link",
email_change: "Confirm your new email",
invite: "You have been invited",
};
​
type HookPayload = {
user: { email: string };
email_data: { token_hash: string; email_action_type: string; redirect_to: string };
};
​
export async function handler(req: Request): Promise<Response> {
if (req.method !== "POST") return new Response("not allowed", { status: 405 });
let payload: HookPayload;
try {
payload = hook.verify(await req.text(), Object.fromEntries(req.headers)) as HookPayload;
} catch {
return new Response("invalid signature", { status: 401 });
}
const { user, email_data } = payload;
const verify = new URLSearchParams({
token: email_data.token_hash,
type: email_data.email_action_type,
redirect_to: email_data.redirect_to,
});
const link = `https://${process.env.SUPABASE_PROJECT_REF}.supabase.co/auth/v1/verify?${verify}`;
try {
await mail.emails.send(
{
from: "Acme <auth@yourdomain.com>",
to: user.email,
subject: SUBJECTS[email_data.email_action_type] ?? "Action required",
html: `<p><a href="${link}">Continue</a></p>`,
text: `Continue: ${link}`,
tracking: false, // never rewrite auth links through a click tracker
},
{ idempotencyKey: `auth:${email_data.token_hash}` },
);
} catch (e) {
const body = JSON.stringify({ error: { http_code: 500, message: String(e) } });
return new Response(body, { status: 500, headers: { "content-type": "application/json" } });
}
return new Response("{}", { status: 200, headers: { "content-type": "application/json" } });
}

Try it without sending real mail

Send to delivered@mailtruss.test first. It works before any domain is verified and reaches no real mailbox, so you can check your code and see the recorded timeline. Then try bounced@mailtruss.test to see what a bounce looks like. All the test addresses are listed under Sending.

See what happened

Open the message under Emails in the dashboard: it shows the recorded timeline, what each event proves, and a diagnosis if something went wrong. Or fetch it with GET /api/v1/emails/:id/events. To be told as it happens, add a webhook.

If it fails

API requests answer with an error name and message. The common ones:

  • 403 invalid_api_key or 401 missing_api_key: the key is wrong, revoked or not sent. Send it as Authorization: Bearer ms_live_….
  • 403 domain_not_verified: the from address is on a domain that is not verified. Publish the DNS records and re-check.
  • 422 validation_error: fix the field the message names. Nothing was sent and no quota was used.
  • 429 rate_limit_exceeded: back off for the Retry-After header. See rate limits.

Problems that happen after a message is accepted (bounces, complaints) are explained under delivery errors.

Other guides

Last updated

Something wrong or unclear on this page?

Report an issue