Clerk
Send Clerk’s verification codes, magic links and invitations from your own domain through SendSend.
Clerk can stop sending an email itself and tell your server to send it instead. Your server receives Clerk’s ready-made subject and HTML, checks the signature, and sends it with SendSend, so what you design in Clerk’s email templates is what goes out.
Set it up
- Verify a sending domain in SendSend, and create an API key with only
emails:send. - In Clerk, open Customization → Emails, and for each template you want to send yourself, turn off Delivered by Clerk.
- In Clerk’s Webhooks, add an endpoint pointing at your route, and subscribe to the email event listed in its Event Catalog. Copy the signing secret.
- Add the route below to your Next.js app, install
svix, and setCLERK_EMAIL_WEBHOOK_SECRET,SENDSEND_API_KEYandSENDSEND_FROM.
// Next.js route handler: sends your Clerk emails (verification codes, magic links, invitations) through SendSend.
//
// 1. Clerk Dashboard -> Customization -> Emails: turn OFF "Delivered by Clerk" for the templates you want to send yourself.
// 2. Clerk Dashboard -> Webhooks -> Add endpoint -> https://your-app.com/api/clerk-email, and subscribe to the email event listed in the
// Event Catalog tab (Clerk's docs call it `emails.created`; the handler accepts that and `email.created`). Copy its signing secret.
// 3. Set CLERK_EMAIL_WEBHOOK_SECRET (whsec_...), SENDSEND_API_KEY and SENDSEND_FROM in your environment.
// 4. npm install svix (put this file at app/api/clerk-email/route.ts)
//
// Clerk fills in the subject and the HTML for each template, so what you design in Clerk is what is sent.
// The field names below (to_email_address, subject, body, body_plain) are what Clerk's email event carries at the time of writing;
// check the Event Catalog for your instance, and adjust here if yours differ. Not tested against a live Clerk instance.
// Leave click tracking OFF for this domain: these emails carry sign-in links.
import { Webhook } from 'svix';
interface ClerkEmail { to_email_address: string; subject: string; body: string; body_plain?: string | null; from_email_name?: string; delivered_by_clerk?: boolean }
export async function POST(req: Request) {
const body = await req.text();
let event: { type: string; data: ClerkEmail };
try {
event = new Webhook(process.env.CLERK_EMAIL_WEBHOOK_SECRET!).verify(body, {
'svix-id': req.headers.get('svix-id') ?? '', 'svix-timestamp': req.headers.get('svix-timestamp') ?? '', 'svix-signature': req.headers.get('svix-signature') ?? '',
}) as typeof event;
} catch { return new Response('Invalid signature', { status: 400 }); }
if (!['email.created', 'emails.created'].includes(event.type) || event.data.delivered_by_clerk) return new Response('ignored');
const e = event.data;
if (!e.to_email_address || !e.subject || !e.body) return new Response('Unexpected payload: check the field names in the Event Catalog', { status: 422 });
const res = await fetch('https://api.sendsend.email/v1/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.SENDSEND_API_KEY}`, 'Content-Type': 'application/json',
// The svix-id is the same on every retry of the same webhook, so Clerk retries can never send twice.
'Idempotency-Key': `clerk-${req.headers.get('svix-id')}`,
},
body: JSON.stringify({ from: process.env.SENDSEND_FROM, to: [e.to_email_address], subject: e.subject, html: e.body, ...(e.body_plain ? { text: e.body_plain } : {}) }),
});
// A non-2xx makes Clerk retry; the idempotency key keeps the retry safe.
return new Response(res.ok ? 'sent' : 'failed', { status: res.ok ? 200 : 502 });
}Notes
- Leave click tracking off for this domain: these emails carry sign-in links.
- Clerk retries a failed webhook. The webhook’s
svix-idis used as the idempotency key, so a retry never sends twice. - Turn “Delivered by Clerk” off one template at a time and test each: while it is on, Clerk keeps sending that email itself.
- The field names in the code are what Clerk’s email event carried when this was written, and the example has not been run against a live Clerk instance. If your Event Catalog shows different names, change them in the route. The route answers 422 if the fields it needs are missing.