Clerk
Send Clerk auth and lifecycle email with Samva.
Clerk
Use Clerk webhooks to send auth and lifecycle email through Samva. Clerk verifies
users and signs webhook events; your route verifies the Svix signature with
Clerk's helper, then sends through the samva SDK.
Clerk owns authentication. Samva sends from the verified sender configured on
your account, so there is no from field in the send payload.
Install
bun add @clerk/nextjs@^7.5.10 samva server-onlyAdd React Email when you want component templates:
bun add @react-email/render @react-email/components react react-domUse a Clerk version that resolves @clerk/backend >=2.4.0. Clerk fixed an
improper webhook-signature acceptance issue in that line. @clerk/nextjs@6.23.3
and newer include the patched v2 backend dependency, and
@clerk/nextjs@^7.5.10 already resolves to the patched v3 backend line.
Create a server-only Samva client
// lib/samva.ts
import "server-only";
import { createClient } from "samva";
const apiKey = process.env.SAMVA_API_KEY;
if (!apiKey) {
throw new Error("SAMVA_API_KEY is not set.");
}
export const samva = createClient({ apiKey });Configure both secrets only on the server:
SAMVA_API_KEY=sk_sm_...
CLERK_WEBHOOK_SIGNING_SECRET=whsec_...Keep the webhook route public
Clerk webhook requests are signed, but they are not signed-in user requests. Make
the webhook path public in clerkMiddleware().
// proxy.ts in Next.js 16+. Use middleware.ts in Next.js 15 and earlier.
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
const isPublicRoute = createRouteMatcher(["/", "/api/webhooks/clerk"]);
export default clerkMiddleware(async (auth, request) => {
if (!isPublicRoute(request)) {
await auth.protect();
}
});Send a welcome email on user.created
// app/api/webhooks/clerk/route.ts
import { verifyWebhook } from "@clerk/nextjs/webhooks";
import { render, toPlainText } from "@react-email/render";
import type { NextRequest } from "next/server";
import WelcomeEmail from "../../../../emails/welcome";
import { samva } from "../../../../lib/samva";
export const runtime = "edge";
export async function POST(request: NextRequest) {
let event: Awaited<ReturnType<typeof verifyWebhook>>;
try {
event = await verifyWebhook(request);
} catch {
return new Response("Verification failed", { status: 400 });
}
if (event.type === "user.created") {
const email =
event.data.email_addresses.find(
(address) => address.id === event.data.primary_email_address_id,
)?.email_address ?? event.data.email_addresses[0]?.email_address;
if (email) {
try {
const html = await render(
WelcomeEmail(event.data.first_name ? { firstName: event.data.first_name } : {}),
);
await samva.messages.send({
to: [{ email }],
channel: "email",
email: {
subject: "Welcome",
html,
text: toPlainText(html),
},
});
} catch (error) {
console.error("Failed to send Clerk welcome email", error);
return new Response("OK", { status: 200 });
}
}
}
return new Response("OK", { status: 200 });
}verifyWebhook() reads the raw request body and checks the Svix headers. Do not
parse JSON before verification. Return 400 for missing or invalid signatures.
Custom delivery with email.created
For Clerk-rendered auth email, open a Clerk email template and turn off
Delivered by Clerk. Clerk emits email.created; forward the rendered email
through Samva:
if (event.type === "email.created") {
const to = event.data.to_email_address;
if (to) {
try {
await samva.messages.send({
to: [{ email: to }],
channel: "email",
email: {
subject: event.data.subject ?? "Clerk email",
html: event.data.body ?? undefined,
text: event.data.body_plain ?? undefined,
},
});
} catch (error) {
console.error("Failed to send Clerk auth email", error);
}
}
}You can also render your own React Email template from event.data.data.
otp_code is documented for verification email; other keys vary by template
slug, so inspect a first live event before relying on them.
Runtime notes
verifyWebhook() accepts a standard web Request, and the Samva SDK is
fetch-based. The same pattern works in Next.js Route Handlers, Vercel Edge
Functions, Cloudflare Workers, and Hono routes that expose a web request.
Use the svix-id header as your idempotency key if duplicate sends matter.
Svix retries non-2xx responses.
Cookbook and example
The cookbook and example links resolve after the companion
samva-integrations PR lands.