Custom SMTP and custom email delivery in Clerk
Clerk sends verification codes, magic links, password resets, and invitations for you, from Clerk's own infrastructure. That is the right default and the wrong production setup, because a verification code arriving from a domain your user has never seen is the exact shape they have been trained to distrust.
4 min read
What you get by default and what it costs
Out of the box, Clerk's development instance sends from a Clerk address with a development banner attached. A production instance sends from your domain once you complete DNS setup, which already fixes most of the problem. The remaining reasons to take over delivery:
- You want one delivery log for every message your product sends, auth included.
- You want the message body in version control rather than in a dashboard.
- You need content Clerk does not have, like the workspace the invitation belongs to.
- You need to localise from a field on your own user record.
If none of those apply, finish Clerk's production DNS setup and stop there. It is less code and fewer things to break.
Path one: DNS on a production instance
Clerk gives you a set of DNS entries to publish when you create a production instance: a CNAME for the frontend API, and mail entries so Clerk can sign as your domain. Publish them at your registrar, wait for verification, and auth mail starts arriving from noreply@acme.com rather than from Clerk.
Two things to check afterwards, because they are the ones that quietly stay broken:
SPF lookup budget. Adding Clerk's include to an SPF record that already carries your marketing platform and your transactional provider can push you past the ten lookup limit, at which point SPF fails for everything. Count them with the SPF lookup checker.
DMARC alignment. Passing SPF is not the same as aligning. Send yourself a verification code and read the Authentication-Results header: you want dkim=pass with header.d=acme.com, not with Clerk's domain. The reasoning is in SPF, DKIM, and DMARC explained for developers.
Path two: take delivery over with a webhook
Clerk can hand you the message instead of sending it. In the dashboard, turn off Clerk's own delivery for the email channel and subscribe a webhook endpoint to the email.created event. Clerk builds the message and posts it to you; you send it.
The payload carries the rendered subject and body along with the slug identifying which message it is.
{
"type": "email.created",
"data": {
"id": "ema_2t9x...",
"to_email_address": "someone@example.com",
"from_email_name": "Acme",
"subject": "Verify your email address",
"body": "<p>Your code is 918273</p>",
"body_plain": "Your code is 918273",
"slug": "verification_code",
"data": { "otp_code": "918273", "app_name": "Acme" }
}
}Your handler verifies the signature, then sends.
import { Webhook } from 'svix';
export const runtime = 'nodejs';
export async function POST(request: Request) {
const body = await request.text();
const headers = Object.fromEntries(request.headers);
let event: { type: string; data: Record<string, string> };
try {
const wh = new Webhook(process.env.CLERK_WEBHOOK_SECRET!);
event = wh.verify(body, headers) as typeof event;
} catch {
return new Response('bad signature', { status: 400 });
}
if (event.type !== 'email.created') return new Response('ignored', { status: 200 });
const d = event.data;
const res = await fetch('https://emails.sh/v1/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.EMAILSSH_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
from: `${d.from_email_name} <auth@acme.com>`,
to: [d.to_email_address],
subject: d.subject,
html: d.body,
text: d.body_plain,
tags: { source: 'clerk', slug: d.slug },
idempotency_key: `clerk:${d.id}`
})
});
if (!res.ok) return new Response('send failed', { status: 500 });
return new Response('ok', { status: 200 });
}Three things in that handler are load-bearing.
Verify before you parse. The endpoint is public and it sends mail. Without signature verification, anyone who finds the URL can make your domain send whatever they post.
`idempotency_key` from Clerk's message id. Webhook systems retry on timeout, and a retry without a key is a second verification code in the user's inbox and a support ticket about which one is real. Why this matters in general is in idempotency keys and retries.
Return 200 fast. If your handler is slow, Clerk times out and retries, and now you are relying on the idempotency key rather than merely being protected by it.
Replacing the body entirely
If you want your own templates rather than Clerk's, branch on slug and ignore body. The data object carries the substitutions, so the code, the app name, and the action URL are all available.
const render = {
verification_code: (d: Record<string, string>) => ({
subject: `${d.otp_code} is your Acme code`,
html: `<p>Your code is <strong>${d.otp_code}</strong>. It expires in ten minutes.</p>`,
text: `Your code is ${d.otp_code}. It expires in ten minutes.`
}),
reset_password_code: (d: Record<string, string>) => ({
subject: 'Reset your Acme password',
html: `<p>Your reset code is <strong>${d.otp_code}</strong>.</p>`,
text: `Your reset code is ${d.otp_code}.`
})
};Putting the code in the subject line is a small thing that users notice, because it means they can read it from the notification without opening the message.
Which path
| Situation | Path |
|---|---|
| You want auth mail from your domain, nothing more | Production instance DNS |
| You want one delivery log across the whole product | Webhook |
| Templates must be reviewed in pull requests | Webhook |
| You need workspace or plan data in the message | Webhook, with your own render |
| You have no place to host a webhook endpoint | Production instance DNS |
The full framework integration is at Clerk, and the equivalent for the other common auth stack is custom email for Supabase Auth. If you are wiring auth into a Next.js app, how to send email in Next.js with the App Router covers where the handler goes.
Questions
- Does Clerk support custom SMTP?
- Clerk's supported route for sending through your own infrastructure is to disable its delivery and subscribe to the
email.createdwebhook, then send the message yourself over your provider's API. A production instance with your DNS published already puts your domain on the mail without any code. - How do I stop Clerk from sending its own copy?
- Turn off Clerk's delivery for the email channel in the dashboard before you enable your webhook handler. If both are active, users receive the message twice.
- Do I need to verify the Clerk webhook signature?
- Yes. The endpoint is publicly reachable and it triggers sends. Clerk signs with Svix headers, so verify with the signing secret and reject anything that fails, before you parse the body.
- What happens if my endpoint is down?
- Clerk retries the webhook with backoff, so a brief outage is recoverable. Use the Clerk message id as your idempotency key so retries after a partial success do not send a second code.
Give your agent an address it can answer from.
Create an inbox