# Clerk custom SMTP and custom email delivery

Send Clerk auth emails from your own domain with emails.sh, plus the webhook route for your own welcome and lifecycle mail.

Clerk sends verification and magic link mail for you, from a Clerk domain unless you tell it otherwise. This page covers pointing Clerk's email delivery at your own sender, and the webhook route that sends your own welcome email when a user is created.

## Setup

1. **Create a key** https://emails.sh/dashboard issues a key starting with esh_.
1. **Check your Clerk plan for custom email delivery** Custom SMTP and custom sender configuration are plan dependent at Clerk. Open Customization, Emails in the Clerk dashboard and see what your plan exposes before you build around it.
1. **Add a webhook endpoint in Clerk** Webhooks, Add Endpoint, subscribe to user.created. Clerk signs with Svix, and gives you a signing secret starting with whsec_.
1. **Verify every webhook** The endpoint is a public URL. Without signature verification anyone can POST a fake user.created and make you send mail to an address they chose.

## Install

```bash
npm install @emails.sh/sdk svix
```

## .env.local

```bash
CLERK_SECRET_KEY=sk_test_your_key
CLERK_WEBHOOK_SIGNING_SECRET=whsec_your_secret
EMAILSSH_API_KEY=esh_your_key_here
EMAILSSH_FROM="Acme <hello@acme.com>"
```

## The Clerk webhook

app/api/webhooks/clerk/route.ts (Next.js App Router)

```ts
import { Webhook } from 'svix';
import { Emailssh } from '@emails.sh/sdk';
import type { WebhookEvent } from '@clerk/nextjs/server';

const mail = new Emailssh({ apiKey: process.env.EMAILSSH_API_KEY! });

export async function POST(request: Request) {
	// The raw body, before any parsing. Svix signs these exact bytes.
	const payload = await request.text();
	const headers = {
		'svix-id': request.headers.get('svix-id') ?? '',
		'svix-timestamp': request.headers.get('svix-timestamp') ?? '',
		'svix-signature': request.headers.get('svix-signature') ?? ''
	};

	let event: WebhookEvent;
	try {
		const webhook = new Webhook(process.env.CLERK_WEBHOOK_SIGNING_SECRET!);
		event = webhook.verify(payload, headers) as WebhookEvent;
	} catch {
		return Response.json({ error: 'bad signature' }, { status: 401 });
	}

	if (event.type === 'user.created') {
		const primaryId = event.data.primary_email_address_id;
		const address = event.data.email_addresses.find((a) => a.id === primaryId);

		if (address) {
			await mail.send({
				from: process.env.EMAILSSH_FROM!,
				to: [address.email_address],
				subject: 'Welcome to Acme',
				html: `<p>Hi ${event.data.first_name ?? 'there'}, your Acme account is ready.</p>
<p><a href="https://acme.com/dashboard">Open your dashboard</a></p>`,
				text: 'Your Acme account is ready. Open https://acme.com/dashboard',
				// Clerk retries on any non-2xx, so make the send idempotent or a
				// slow response turns into three welcome emails.
				idempotencyKey: `welcome:${event.data.id}`
			});
		}
	}

	return Response.json({ ok: true });
}
```

## Sending to a Clerk user from your own code

app/actions/notify-user.ts

```ts
'use server';

import { clerkClient } from '@clerk/nextjs/server';
import { Emailssh } from '@emails.sh/sdk';

const mail = new Emailssh({ apiKey: process.env.EMAILSSH_API_KEY! });

export async function notifyUser(userId: string, subject: string, html: string) {
	const client = await clerkClient();
	const user = await client.users.getUser(userId);

	const address = user.emailAddresses.find((a) => a.id === user.primaryEmailAddressId);
	if (!address) throw new Error(`User ${userId} has no primary email address`);

	const sent = await mail.send({
		from: process.env.EMAILSSH_FROM!,
		to: [address.emailAddress],
		subject,
		html,
		text: html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
	});

	return sent.id;
}
```

## Worth knowing

### Clerk auth email and your product email are separate problems

Verification codes, magic links, and password resets are sent by Clerk. Welcome mail, receipts, and notifications are yours. The webhook above is the seam between the two, and it does not try to replace Clerk auth mail.

### Custom email delivery depends on your Clerk plan

What you can change about the sender, the domain, and the templates varies by plan. Check Customization, Emails in your dashboard rather than assuming, since building on a feature you do not have costs a day.

### Clerk retries failed webhooks

Any non-2xx response, or a slow one, gets redelivered. Set idempotencyKey on the send and return 2xx as soon as you have verified and queued, or users get duplicate welcome mail.

### Verify with the raw body

Reading request.json() first and re-serialising it changes the bytes, so Svix verification fails with a confusing signature error. Always request.text() first.


## Questions

### Can I send Clerk verification emails from my own domain?

That is controlled by Clerk email delivery settings and depends on your plan. Check Customization, Emails in the Clerk dashboard. Your own product email is separate and always yours to send.

### How do I send a welcome email when someone signs up with Clerk?

Subscribe to the user.created webhook and send from the handler, as above. Do not do it client side after sign-up, since that misses OAuth and invitation flows.

### Why does my Clerk webhook return 400?

Almost always signature verification against a parsed body, or the wrong signing secret. Use request.text() and the secret from that specific endpoint.

### How do I get a user email address from a Clerk user id?

clerkClient().users.getUser(id) and match primaryEmailAddressId against emailAddresses, as in the second file.


Docs: https://emails.sh/docs.md