All dispatchesView as Markdown

How to send Clerk emails with Email Bump

MC
Maya ChenEmail infrastructure at Email Bump

Use Clerk’s signed email.created webhook to deliver verification codes, sign-in links, invitations, and other authentication messages through Email Bump—without breaking Clerk’s auth flows.

Clerk generates authentication messages and delivers them with its own email provider by default. To use Email Bump instead, turn off Delivered by Clerk for each email template you want to own. Clerk will continue to create the verification code, sign-in link, invitation data, subject, HTML, and plain text, then emit a signed email.created webhook. Your server verifies that event and submits the message to Email Bump.

That division of responsibility is useful: Clerk remains the authority for identity and token consumption, while Email Bump becomes the transport and delivery evidence layer. You do not recreate Clerk’s OTPs, verification links, or authentication state in your application.

How the delivery handoff works

Clerk to Email Bump
1. A user begins a Clerk authentication action.
2. Clerk generates the verification link, OTP, or invitation data.
3. Clerk renders the selected email template.
4. Clerk signs and POSTs an email.created webhook to your server.
5. Your server verifies the webhook before reading the payload.
6. Your handler submits the rendered HTML and text to Email Bump.
7. Email Bump records acceptance and later delivery events.
8. The recipient gives the link or code back to Clerk to finish authentication.

The webhook is asynchronous. Your sign-up or sign-in UI should tell the user to check their inbox based on Clerk’s flow state, not wait for an Email Bump delivery event. Submission, receiver delivery, inbox placement, and successful verification are separate milestones.

Set up Email Bump

  • Create an Email Bump project for the same environment as the Clerk instance.
  • Verify a sending domain and choose a stable authentication sender such as Acme <auth@updates.acme.com>.
  • Create a project-scoped API key and keep it in the server’s secret manager.
  • Leave click tracking disabled for authentication email so Clerk action URLs are not rewritten.
  • Use separate Clerk instances, Email Bump projects, API keys, and webhook secrets for development and production.
.env.localbash
CLERK_WEBHOOK_SIGNING_SECRET=whsec_replace_me
EMAIL_BUMP_API_KEY=ebk_replace_me
EMAIL_FROM="Acme <auth@updates.acme.com>"

CLERK_WEBHOOK_SIGNING_SECRET authenticates incoming webhook requests. EMAIL_BUMP_API_KEY authenticates outbound sends. Neither belongs in a browser-visible variable, client bundle, source-control file, or support log.

Configure Clerk one template at a time

  • Open the Clerk Dashboard and choose the development instance.
  • Open Emails and select one template, such as verification code.
  • Review its subject, From local part, Reply-To, HTML, variables, and preview.
  • Turn off Delivered by Clerk for that template only.
  • Create a webhook endpoint subscribed to email.created and copy its signing secret.
  • Trigger the real development flow and inspect the webhook attempt before migrating another template.

The From value shown in Clerk’s template settings does not authenticate your Email Bump sender. Configure the same visible identity in both systems, but treat EMAIL_FROM and the verified Email Bump domain as the transport authority.

Create the Email Bump adapter

lib/email-bump.tstypescript
type ClerkEmail = {
  clerkEmailId: string;
  to: string;
  subject: string;
  html: string;
  text: string;
};

export class EmailBumpError extends Error {
  constructor(
    message: string,
    readonly kind: "transient" | "permanent" | "ambiguous",
    readonly status?: number,
  ) {
    super(message);
  }
}

function required(name: string): string {
  const value = process.env[name]?.trim();
  if (!value) throw new Error(`Missing environment variable: ${name}`);
  return value;
}

export async function sendClerkEmail(message: ClerkEmail) {
  let response: Response;
  try {
    response = await fetch("https://emailbump.com/api/v1/emails", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${required("EMAIL_BUMP_API_KEY")}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        from: required("EMAIL_FROM"),
        to: message.to,
        subject: message.subject,
        html: message.html,
        text: message.text,
      }),
      signal: AbortSignal.timeout(10_000),
    });
  } catch {
    throw new EmailBumpError("Email request ended without a response", "ambiguous");
  }

  if (response.ok) {
    return (await response.json()) as { id: string; message_id: string };
  }
  if (response.status === 408 || response.status === 429 || response.status >= 500) {
    throw new EmailBumpError("Temporary Email Bump failure", "transient", response.status);
  }
  throw new EmailBumpError("Email Bump rejected the message", "permanent", response.status);
}

Keep response classification in the adapter. A 400-series configuration or content error usually needs an operator fix. A 429 or server error can be retried. A connection failure or timeout is ambiguous because Email Bump may have accepted the message before the response was lost.

Verify and deliver Clerk’s email.created webhook

Clerk signs webhooks using the Standard Webhooks protocol. Use Clerk’s verifyWebhook helper so signature and timestamp validation happen before the payload is trusted. The route must be publicly reachable, but it must not be allowed through an authentication middleware only to skip signature verification.

app/api/webhooks/clerk/route.tstypescript
import { verifyWebhook } from "@clerk/nextjs/webhooks";
import { sendClerkEmail } from "@/lib/email-bump";

export const runtime = "nodejs";

export async function POST(request: Request) {
  let event;
  try {
    event = await verifyWebhook(request);
  } catch {
    return new Response("Invalid webhook signature", { status: 400 });
  }

  if (event.type !== "email.created") {
    return new Response("Ignored", { status: 200 });
  }

  const email = event.data;
  if (email.delivered_by_clerk) {
    return new Response("Already delivered by Clerk", { status: 200 });
  }

  try {
    await sendClerkEmail({
      clerkEmailId: email.id,
      to: email.to_email_address,
      subject: email.subject,
      html: email.body,
      text: email.body_plain,
    });
    return new Response("Delivered to Email Bump", { status: 200 });
  } catch {
    // A non-2xx response asks Clerk's webhook service to retry.
    // Report IDs and status classes, never the body or template data.
    return new Response("Temporary delivery failure", { status: 500 });
  }
}

This direct handler is a good first integration test. It passes Clerk’s rendered subject, body, and body_plain through unchanged. The payload can also contain a template slug and raw data such as otp_code, but you do not need to touch those secrets when Clerk’s rendered content already matches your product.

Keep the webhook route public but isolated

Incoming Clerk webhooks do not carry a user session. If Clerk middleware protects every application route, explicitly exclude the webhook path from session enforcement. Public reachability is only transport access; verifyWebhook remains the authorization boundary.

  • Accept POST only and apply a small request-body limit at the proxy or application boundary.
  • Verify the signature before parsing fields or performing any side effect.
  • Keep a different signing secret for each Clerk instance and endpoint.
  • Do not log the raw body, headers, OTP, action URL, rendered HTML, or recipient address.
  • Return 200 for valid event types you intentionally ignore so Clerk does not retry them.
  • Return a non-2xx status for temporary processing failures only when a retry is useful.

Prevent duplicate authentication email

Webhook delivery is at least once. Clerk can retry after a non-2xx response, and an operator can replay an event from the dashboard. A response can also be lost after Email Bump accepts the message. Therefore, the same Clerk email ID or Svix message ID may reach your endpoint more than once.

Webhook receipt tablesql
CREATE TABLE clerk_email_receipts (
  clerk_email_id        text PRIMARY KEY,
  svix_message_id       text NOT NULL UNIQUE,
  template_slug         text NOT NULL,
  state                 text NOT NULL DEFAULT 'pending',
  email_bump_message_id text,
  attempt_count         integer NOT NULL DEFAULT 0,
  last_error_class      text,
  created_at            timestamptz NOT NULL DEFAULT now(),
  updated_at            timestamptz NOT NULL DEFAULT now()
);

Read the svix-id header before verification, then insert a receipt keyed by both that value and event.data.id. A duplicate insert should return 200 without sending again when the original outcome is known. Do not use the recipient or template slug as a deduplication key: one user can legitimately request several verification messages.

A database uniqueness constraint prevents concurrent duplicate handling, but it cannot guarantee exactly-once submission across your database and a remote email API. If the Email Bump request times out after possible acceptance, mark the receipt ambiguous and reconcile activity before resending a high-impact message. For stronger crash recovery, store a protected email job and let a leased worker deliver it.

Handle retries by failure class

Outcome policy
OUTCOME                         ACTION
Invalid Clerk signature         Return 400; no processing
Unrelated verified event        Return 200; no retry
Duplicate completed event       Return 200; no resend
Email Bump 400/401/403/422       Mark blocked; alert; fix configuration
Email Bump 408/429/5xx           Retry with bounded backoff and jitter
Network error before response   Mark ambiguous; reconcile before resend
Email Bump accepted              Save message ID; return 200
Webhook response lost           Deduplicate the repeated Clerk event

Do not repeatedly ask Clerk to retry a permanent sender or payload problem without an operational limit. Persist the failed receipt, alert with non-sensitive correlation IDs, repair the configuration, and replay the event from Clerk while its verification material is still valid.

Use Clerk’s rendered template or render your own

Passing subject, body, and body_plain through is the safest starting point because the copy visible in Clerk’s preview is the copy Email Bump receives. Clerk keeps template variables and link construction aligned with its authentication flow.

Rendering decision
CLERK-RENDERED CONTENT
Use when: migrating transport, preserving dashboard-managed copy, minimizing code
Read: subject, body, body_plain, to_email_address

APPLICATION-RENDERED CONTENT
Use when: templates live in code, localization or design requires another renderer
Read: slug plus an allowlisted subset of data
Own: escaping, required variables, HTML, plain text, preview, versioning, expiry copy

NEVER
Treat arbitrary webhook metadata as trusted HTML or build a generic template executor

If you render your own templates, switch on an explicit allowlist of Clerk slugs and validate every required field. Escape names and organization values. Treat otp_code and action URLs as credentials. Reject unknown slugs instead of guessing how to render them, and keep a fallback procedure for a Clerk template added later.

Protect verification links and OTPs

  • Do not rewrite Clerk action URLs through click tracking or application analytics redirects.
  • Do not load third-party scripts, remote personalization, or unnecessary images in authentication messages.
  • Do not put OTPs or action URLs in logs, exception messages, queue dashboards, traces, or metrics labels.
  • Encrypt queued payloads at rest and restrict them to the webhook receiver and email worker.
  • Expire pending jobs before their underlying Clerk code or link becomes useless.
  • Keep authentication copy focused on the requested action and out of promotional automations.
  • State what the code or link authorizes, when it expires, and what to do if the recipient did not request it.

Send welcome and lifecycle email from Clerk user events

Clerk system email and product lifecycle email should use different webhook intents. Subscribe to user.created when a completed Clerk account should enter an onboarding workflow. Verify the event, select the primary email address by primary_email_address_id, and create or update an Email Bump contact or emit an internal onboarding event.

Primary address selection
if (event.type === "user.created") {
  const user = event.data;
  const primary = user.email_addresses.find(
    (address) => address.id === user.primary_email_address_id,
  );

  if (primary?.verification?.status === "verified") {
    await enqueueWelcomeEmailOnce({
      clerkUserId: user.id,
      email: primary.email_address,
      firstName: user.first_name,
    });
  }
}

Webhooks are eventually consistent, so do not make the user-created event a synchronous prerequisite for the first page after sign-up. Deduplicate by Clerk user ID and welcome-template version. If lifecycle email becomes promotional, apply the appropriate consent, preference, and unsubscribe policy rather than assuming every Clerk user opted in.

Test the migration safely

Test matrix
DASHBOARD
correct Clerk instance · one template disabled · email.created subscription · signing secret

SIGNATURE
valid event · wrong secret · missing Svix headers · stale or modified payload

MESSAGE
verification code · email link · password recovery · invitation · HTML · plain text

DELIVERY
Email Bump accepted · 400 permanent · 429 retry · 500 retry · ambiguous timeout

DUPLICATES
same svix-id · same Clerk email ID · dashboard replay · response lost after send

SECURITY
no click rewriting · no secret logs · encrypted queued payload · expired-job suppression

LIFECYCLE
user.created primary address · verified status · duplicate event · consent boundary

Use Clerk’s development instance and webhook Testing tab first. Then trigger the real authentication flow with an inbox you control: a sample event proves the handler shape, while a real event proves the selected template, recipient, code or link, and Clerk verification state work together. Confirm the Email Bump activity timeline and consume the credential exactly once.

Production checklist

  • Verify the Email Bump sender and validate all required secrets at startup.
  • Subscribe the production Clerk endpoint to email.created and store its production signing secret.
  • Migrate Clerk templates individually and test each real auth flow before moving to the next.
  • Verify every webhook before reading its data or creating side effects.
  • Deduplicate with database-enforced Clerk email and Svix message identifiers.
  • Classify permanent, transient, and ambiguous Email Bump outcomes separately.
  • Disable click tracking and keep authentication credentials out of telemetry.
  • Protect and expire any queued rendered bodies, links, and OTPs.
  • Monitor oldest pending receipt, retries, permanent failures, ambiguous sends, and delivery latency.
  • Keep user.created lifecycle automation separate from Clerk’s system-email handoff.