DEV Community

Qasim Muhammad
Qasim Muhammad

Posted on • Originally published at developer.nylas.com

Send Clerk Emails Through an Agent Account

A user signs up for your app. Clerk emails them a verification code, they squint at it, hit reply, and type "is this actually you? I got two of these." That reply goes nowhere. Clerk's default sender is unattended, the user's question evaporates, and the first interaction someone has with your product is an email that ignores them.

Clerk's escape hatch for this is unusual — and honestly, kind of elegant. There's no SMTP host/port/password form like Supabase or Auth0 have. Instead, each email template carries a Delivered by Clerk toggle. Flip it off and Clerk still renders the email — template, OTP code, magic link, all of it — but instead of sending, it hands the finished email to your application through an emails.created webhook. Delivery becomes your problem, which means delivery becomes your choice.

A good choice: relay it to a Nylas Agent Account — a hosted mailbox you control entirely through an API, currently in beta. Clerk keeps the auth flow and templates; the mailbox sends the email and, the real prize, catches replies. The whole integration is two webhooks: emails.created in, message.created back when someone responds.

Provision the sending mailbox

No app_password needed here — your handler calls the REST API directly with your standard API key:

curl --request POST \
  --url "https://api.us.nylas.com/v3/connect/custom" \
  --header "Authorization: Bearer $NYLAS_API_KEY" \
  --header "Content-Type: application/json" \
  --data '{
    "provider": "nylas",
    "settings": { "email": "noreply@auth.yourcompany.com" }
  }'
Enter fullscreen mode Exit fullscreen mode

Stash the returned grant_id next to your API key. Domain prerequisite: a *.nylas.email trial subdomain works instantly for prototyping; production wants your own domain with MX and TXT records, per the provisioning guide.

Flip the toggle — one template at a time

In the Clerk Dashboard under Customization → Emails, open a template and switch Delivered by Clerk off. The toggle is per-template, so there's no flag day: move verification first, watch it in production, then migrate magic links and invitations once you trust the pipe. Anything left on keeps shipping through Clerk's default ESP untouched.

While you're there, set the From local part to match your mailbox. Clerk composes the sender as <local part>@<your-clerk-mail-domain> — if that domain and your provisioned domain drift apart, the address users see in previews and the envelope address on the wire won't match, and deliverability pays for it.

Catch the webhook, relay the send

Subscribe to emails.created on Clerk's Webhooks page, then verify and relay:

import { verifyWebhook } from "@clerk/backend/webhooks";

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

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

  await deliverViaNylas(event.data);
  return new Response("ok", { status: 200 });
}
Enter fullscreen mode Exit fullscreen mode

deliverViaNylas is where Clerk's webhook becomes a real send — a single POST /v3/grants/{grant_id}/messages/send with Clerk's rendered HTML as the body:

async function deliverViaNylas(email) {
  const res = await fetch(
    `https://api.us.nylas.com/v3/grants/${process.env.NYLAS_GRANT_ID}/messages/send`,
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.NYLAS_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        subject: email.subject,
        body: email.body, // Clerk's rendered HTML
        to: [{ email: email.to_email_address }],
      }),
    }
  );

  if (!res.ok) {
    throw new Error(`Nylas send failed: ${res.status} ${await res.text()}`);
  }
}
Enter fullscreen mode Exit fullscreen mode

Log the full event.data payload once on your first real delivery — it carries the subject, HTML body, plain-text fallback, template slug, and a nested data field with the OTP or magic-link URL — and compare it against the Clerk Dashboard's Send example payload to confirm the shape before depending on it. The field names are stable per template, but five minutes of inspection beats a production surprise.

The three production rules

Clerk retries on non-2xx and on timeouts, which makes your handler's response semantics load-bearing:

  1. Dedupe by the Svix message ID. If your send succeeds but the 200 never reaches Clerk, the retry will double-send a verification code. Use the Svix ID from the request headers as an idempotency key.
  2. Answer fast, send async. A slow handler looks like a failure and triggers a retry. Return 200 and queue the send if it might be slow.
  3. 4xx is permanent, 5xx is retryable. A bad recipient won't improve on retry — acknowledge with 200 and log it. A 5xx from the send API is worth letting Clerk re-fire.

When something breaks, Clerk's Message Attempts view shows every delivery, your response code, and the raw payload; the grant logs on the other side show whether the send arrived at all.

Why bother: the reply comes back

Test it end to end — create a user, get the verification email, and check the mailbox's Sent folder via GET /v3/grants/{grant_id}/messages?in=sent. Then reply to that email. It lands in the mailbox's inbox and fires message.created, and suddenly the scenario from the top of this post has a different ending: the confused user's question reaches your reply-handling loop instead of the void.

One capacity note: the send cap is 200 messages per account per day on the free plan — fine for early traffic, but a B2C app with steady signups should move to a paid plan or shard across multiple accounts, picking one per recipient.

If you'd rather speak SMTP

This recipe calls the REST API because it's the most direct path from a webhook handler — no credentials beyond the API key you already have. But the same Agent Account can serve an SMTP transport too: set an app_password on the grant (at creation or later) and point an existing nodemailer transport at mail.us.nylas.email:587. That's handy if your codebase already has a mail abstraction you don't want to rework. Same mailbox, same Sent folder, same replies coming back — just a different wire protocol on the way out.

The full recipe is at send Clerk emails through an Agent Account. Start with exactly one template — verification — flipped off in a dev instance, and see how far you get in an afternoon. Which template would you migrate first?

Top comments (0)