From 5d2dd91e299a0ab6db4e8d543a7c58fc50e77648 Mon Sep 17 00:00:00 2001
From: Serge <8313760+cbnsndwch@users.noreply.github.com>
Date: Thu, 18 Jun 2026 20:03:48 +0000
Subject: [PATCH 1/6] feat(backend): add experimental emails.create for sending
transactional email
---
.changeset/clerk-email-send.md | 5 ++
.../src/api/__tests__/EmailApi.test.ts | 61 +++++++++++++++++++
.../backend/src/api/endpoints/EmailApi.ts | 41 +++++++++++++
packages/backend/src/api/endpoints/index.ts | 1 +
packages/backend/src/api/factory.ts | 7 +++
5 files changed, 115 insertions(+)
create mode 100644 .changeset/clerk-email-send.md
create mode 100644 packages/backend/src/api/__tests__/EmailApi.test.ts
create mode 100644 packages/backend/src/api/endpoints/EmailApi.ts
diff --git a/.changeset/clerk-email-send.md b/.changeset/clerk-email-send.md
new file mode 100644
index 00000000000..28838558b01
--- /dev/null
+++ b/.changeset/clerk-email-send.md
@@ -0,0 +1,5 @@
+---
+'@clerk/backend': minor
+---
+
+Add an experimental `clerkClient.emails.create()` method for sending a transactional email through the Clerk Backend API. Accepts `to`, `from`, optional `replyTo`, `subject`, and `html`/`text` content and returns the created `Email` resource. The underlying endpoint is internal and not yet generally available, so the method is marked `@experimental` and is subject to change; pin your SDK version if you rely on it.
diff --git a/packages/backend/src/api/__tests__/EmailApi.test.ts b/packages/backend/src/api/__tests__/EmailApi.test.ts
new file mode 100644
index 00000000000..012c5ef5ec0
--- /dev/null
+++ b/packages/backend/src/api/__tests__/EmailApi.test.ts
@@ -0,0 +1,61 @@
+import { http, HttpResponse } from 'msw';
+import { describe, expect, it } from 'vitest';
+
+import { server, validateHeaders } from '../../mock-server';
+import { createBackendApiClient } from '../factory';
+
+describe('EmailApi', () => {
+ const apiClient = createBackendApiClient({
+ apiUrl: 'https://api.clerk.test',
+ secretKey: 'deadbeef',
+ });
+
+ const mockEmail = {
+ object: 'email',
+ id: 'ema_123',
+ slug: null,
+ from_email_name: 'noreply',
+ reply_to_email_name: null,
+ to_email_address: 'admin@acme.com',
+ email_address_id: null,
+ user_id: null,
+ subject: 'Hello',
+ body: '
hi
',
+ body_plain: null,
+ status: 'queued',
+ data: null,
+ delivered_by_clerk: true,
+ };
+
+ it('sends a transactional email and snake_cases the body', async () => {
+ server.use(
+ http.post(
+ 'https://api.clerk.test/v1/email',
+ validateHeaders(async ({ request }) => {
+ const body = await request.json();
+ expect(body).toEqual({
+ to: { address: 'admin@acme.com' },
+ from: { address: 'noreply@acme.com' },
+ reply_to: { address: 'support@acme.com' },
+ subject: 'Hello',
+ html: 'hi
',
+ });
+ return HttpResponse.json(mockEmail);
+ }),
+ ),
+ );
+
+ const response = await apiClient.emails.create({
+ to: { address: 'admin@acme.com' },
+ from: { address: 'noreply@acme.com' },
+ replyTo: { address: 'support@acme.com' },
+ subject: 'Hello',
+ html: 'hi
',
+ });
+
+ expect(response.id).toBe('ema_123');
+ expect(response.toEmailAddress).toBe('admin@acme.com');
+ expect(response.status).toBe('queued');
+ expect(response.deliveredByClerk).toBe(true);
+ });
+});
diff --git a/packages/backend/src/api/endpoints/EmailApi.ts b/packages/backend/src/api/endpoints/EmailApi.ts
new file mode 100644
index 00000000000..3f182e01665
--- /dev/null
+++ b/packages/backend/src/api/endpoints/EmailApi.ts
@@ -0,0 +1,41 @@
+import type { Email } from '../resources/Email';
+import { AbstractAPI } from './AbstractApi';
+
+const basePath = '/email';
+
+type Mailbox = {
+ /**
+ * Display name for the mailbox. Currently accepted by the API but not yet
+ * rendered server-side, so it has no effect on the delivered email for now.
+ */
+ name?: string;
+ address: string;
+};
+
+export type CreateEmailParams = {
+ to: Mailbox;
+ from: Mailbox;
+ // Top-level camelCase keys are snake_cased automatically, so `replyTo` is
+ // sent as `reply_to`.
+ replyTo?: Mailbox;
+ subject: string;
+ html?: string;
+ text?: string;
+};
+
+export class EmailApi extends AbstractAPI {
+ /**
+ * @experimental This method calls an internal, not-yet-public endpoint and is
+ * subject to change. It is advised to [pin](https://clerk.com/docs/pinning)
+ * the SDK version to avoid breaking changes.
+ *
+ * Sends a transactional email.
+ */
+ public async create(params: CreateEmailParams) {
+ return this.request({
+ method: 'POST',
+ path: basePath,
+ bodyParams: params,
+ });
+ }
+}
diff --git a/packages/backend/src/api/endpoints/index.ts b/packages/backend/src/api/endpoints/index.ts
index ebbd1990b56..32e19b906f5 100644
--- a/packages/backend/src/api/endpoints/index.ts
+++ b/packages/backend/src/api/endpoints/index.ts
@@ -9,6 +9,7 @@ export * from './BlocklistIdentifierApi';
export * from './ClientApi';
export * from './DomainApi';
export * from './EmailAddressApi';
+export * from './EmailApi';
export * from './EnterpriseConnectionApi';
export * from './IdPOAuthAccessTokenApi';
export * from './InstanceApi';
diff --git a/packages/backend/src/api/factory.ts b/packages/backend/src/api/factory.ts
index 1ad4d2fd236..c9b6f700f0f 100644
--- a/packages/backend/src/api/factory.ts
+++ b/packages/backend/src/api/factory.ts
@@ -9,6 +9,7 @@ import {
ClientAPI,
DomainAPI,
EmailAddressAPI,
+ EmailApi,
EnterpriseConnectionAPI,
IdPOAuthAccessTokenApi,
InstanceAPI,
@@ -71,6 +72,12 @@ export function createBackendApiClient(options: CreateBackendApiOptions) {
clients: new ClientAPI(request),
domains: new DomainAPI(request),
emailAddresses: new EmailAddressAPI(request),
+ /**
+ * @experimental This calls an internal, not-yet-public endpoint for sending
+ * transactional emails and is subject to change. It is advised to
+ * [pin](https://clerk.com/docs/pinning) the SDK version to avoid breaking changes.
+ */
+ emails: new EmailApi(request),
enterpriseConnections: new EnterpriseConnectionAPI(request),
idPOAuthAccessToken: new IdPOAuthAccessTokenApi(
buildRequest({
From 7519207e8dec51cb5ce6476c467d8c40e21d9b7d Mon Sep 17 00:00:00 2001
From: cbnsndwch <8313760+cbnsndwch@users.noreply.github.com>
Date: Thu, 18 Jun 2026 16:41:45 -0400
Subject: [PATCH 2/6] feat(backend): add experimental emails.create for sending
transactional email
---
.../backend/src/api/endpoints/EmailApi.ts | 40 +++++++++++++++++--
1 file changed, 36 insertions(+), 4 deletions(-)
diff --git a/packages/backend/src/api/endpoints/EmailApi.ts b/packages/backend/src/api/endpoints/EmailApi.ts
index 3f182e01665..b53ec6f33fe 100644
--- a/packages/backend/src/api/endpoints/EmailApi.ts
+++ b/packages/backend/src/api/endpoints/EmailApi.ts
@@ -3,23 +3,55 @@ import { AbstractAPI } from './AbstractApi';
const basePath = '/email';
+/**
+ * A subset of mailbox object as specified in RFC 5322 ยง3.4. Specifically, a
+ * `name-addr` with an optional `display-name` and a required `addr-spec`.
+ *
+ * @see {@link https://datatracker.ietf.org/doc/html/rfc5322#section-3.4}
+ */
type Mailbox = {
/**
- * Display name for the mailbox. Currently accepted by the API but not yet
- * rendered server-side, so it has no effect on the delivered email for now.
+ * (Optional) Display name for the mailbox. Currently accepted by the API but
+ * not yet rendered server-side, so it has no effect on the delivered email
+ * for now.
*/
name?: string;
+
+ /**
+ * The `addr-spec` of the mailbox, i.e. the email address itself.
+ */
address: string;
};
export type CreateEmailParams = {
+ /**
+ * The recipient of the email. Currently only a single recipient is supported.
+ */
to: Mailbox;
+
+ /**
+ * The sender of the email. See {@link Mailbox} for the accepted format. Note
+ * that the API does not yet render the `name` field of the `from` mailbox.
+ */
from: Mailbox;
- // Top-level camelCase keys are snake_cased automatically, so `replyTo` is
- // sent as `reply_to`.
+
+ /**
+ * (Optional) The mailbox to include in the `reply-to` header of the email.
+ */
replyTo?: Mailbox;
+
subject: string;
+
+ /**
+ * The HTML body of the email. At least one of `html` and `text` must be
+ * provided. If both are provided, the `html` version will take precedence.
+ */
html?: string;
+
+ /**
+ * The plain text body of the email. At least one of `html` and `text` must be
+ * provided. If both are provided, the `html` version will take precedence.
+ */
text?: string;
};
From 76aaebf7e78235c959061d30ca7851310250c91d Mon Sep 17 00:00:00 2001
From: cbnsndwch <8313760+cbnsndwch@users.noreply.github.com>
Date: Thu, 25 Jun 2026 18:32:52 +0000
Subject: [PATCH 3/6] feat(backend): support user_id recipient in emails.create
---
.changeset/clerk-email-send.md | 2 +-
.../src/api/__tests__/EmailApi.test.ts | 35 ++++++++++++++++
.../backend/src/api/endpoints/EmailApi.ts | 42 ++++++++++++++++++-
packages/backend/src/api/resources/Email.ts | 2 +
4 files changed, 79 insertions(+), 2 deletions(-)
diff --git a/.changeset/clerk-email-send.md b/.changeset/clerk-email-send.md
index 28838558b01..2c500d23aba 100644
--- a/.changeset/clerk-email-send.md
+++ b/.changeset/clerk-email-send.md
@@ -2,4 +2,4 @@
'@clerk/backend': minor
---
-Add an experimental `clerkClient.emails.create()` method for sending a transactional email through the Clerk Backend API. Accepts `to`, `from`, optional `replyTo`, `subject`, and `html`/`text` content and returns the created `Email` resource. The underlying endpoint is internal and not yet generally available, so the method is marked `@experimental` and is subject to change; pin your SDK version if you rely on it.
+Add an experimental `clerkClient.emails.create()` method for sending a transactional email through the Clerk Backend API. Accepts `to`, `from`, optional `replyTo`, `subject`, and `html`/`text` content and returns the created `Email` resource. The `to` recipient is mutually exclusive: pass either `{ address }` (with an optional `name`) or `{ userId }`, in which case Clerk resolves that user's primary email address server-side. The underlying endpoint is internal and not yet generally available, so the method is marked `@experimental` and is subject to change; pin your SDK version if you rely on it.
diff --git a/packages/backend/src/api/__tests__/EmailApi.test.ts b/packages/backend/src/api/__tests__/EmailApi.test.ts
index 012c5ef5ec0..f78154917e0 100644
--- a/packages/backend/src/api/__tests__/EmailApi.test.ts
+++ b/packages/backend/src/api/__tests__/EmailApi.test.ts
@@ -58,4 +58,39 @@ describe('EmailApi', () => {
expect(response.status).toBe('queued');
expect(response.deliveredByClerk).toBe(true);
});
+
+ it('sends a transactional email addressed by userId', async () => {
+ server.use(
+ http.post(
+ 'https://api.clerk.test/v1/email',
+ validateHeaders(async ({ request }) => {
+ const body = await request.json();
+ // The nested `userId` must be snake_cased to `user_id` on the wire.
+ expect(body).toEqual({
+ to: { user_id: 'user_123' },
+ from: { address: 'noreply@acme.com' },
+ subject: 'Hello',
+ html: 'hi
',
+ });
+ return HttpResponse.json({
+ ...mockEmail,
+ to_email_address: 'member@acme.com',
+ email_address_id: 'idn_123',
+ user_id: 'user_123',
+ });
+ }),
+ ),
+ );
+
+ const response = await apiClient.emails.create({
+ to: { userId: 'user_123' },
+ from: { address: 'noreply@acme.com' },
+ subject: 'Hello',
+ html: 'hi
',
+ });
+
+ expect(response.toEmailAddress).toBe('member@acme.com');
+ expect(response.emailAddressId).toBe('idn_123');
+ expect(response.userId).toBe('user_123');
+ });
});
diff --git a/packages/backend/src/api/endpoints/EmailApi.ts b/packages/backend/src/api/endpoints/EmailApi.ts
index b53ec6f33fe..a4f8d35d18b 100644
--- a/packages/backend/src/api/endpoints/EmailApi.ts
+++ b/packages/backend/src/api/endpoints/EmailApi.ts
@@ -23,11 +23,45 @@ type Mailbox = {
address: string;
};
+/**
+ * The recipient of the email. Provide exactly one of the two mutually exclusive
+ * forms:
+ *
+ * - a literal mailbox: an `address` (plus an optional `name`), or
+ * - a `userId`: the ID of a Clerk user whose primary email address Clerk
+ * resolves server-side, from the instance the secret key belongs to.
+ */
+type EmailRecipient =
+ | {
+ /**
+ * The `addr-spec` of the recipient mailbox, i.e. the email address itself.
+ */
+ address: string;
+ /**
+ * (Optional) Display name for the recipient mailbox. Currently accepted
+ * by the API but not yet rendered server-side.
+ */
+ name?: string;
+ userId?: never;
+ }
+ | {
+ /**
+ * The ID of the Clerk user to send to. Clerk resolves the user's primary
+ * email address from the instance context. Mutually exclusive with
+ * `address`.
+ */
+ userId: string;
+ address?: never;
+ name?: never;
+ };
+
export type CreateEmailParams = {
/**
* The recipient of the email. Currently only a single recipient is supported.
+ * Provide either an `address` (with an optional `name`) or the `userId` of a
+ * Clerk user; the two forms are mutually exclusive.
*/
- to: Mailbox;
+ to: EmailRecipient;
/**
* The sender of the email. See {@link Mailbox} for the accepted format. Note
@@ -68,6 +102,12 @@ export class EmailApi extends AbstractAPI {
method: 'POST',
path: basePath,
bodyParams: params,
+ options: {
+ // Snakecase nested keys too, so a `to: { userId }` recipient is sent as
+ // `to: { user_id }` on the wire (the default only snakecases top-level
+ // keys, which would leave the nested `userId` untouched).
+ deepSnakecaseBodyParamKeys: true,
+ },
});
}
}
diff --git a/packages/backend/src/api/resources/Email.ts b/packages/backend/src/api/resources/Email.ts
index 7e2cc8810e6..4258adaa6c1 100644
--- a/packages/backend/src/api/resources/Email.ts
+++ b/packages/backend/src/api/resources/Email.ts
@@ -13,6 +13,7 @@ export class Email {
readonly slug?: string | null,
readonly data?: Record | null,
readonly deliveredByClerk?: boolean,
+ readonly userId?: string | null,
) {}
static fromJSON(data: EmailJSON): Email {
@@ -28,6 +29,7 @@ export class Email {
data.slug,
data.data,
data.delivered_by_clerk,
+ data.user_id,
);
}
}
From 01ba8cb4cc84ef5517132387362bc452957389f2 Mon Sep 17 00:00:00 2001
From: Serge the Lion <8313760+cbnsndwch@users.noreply.github.com>
Date: Thu, 25 Jun 2026 21:35:34 -0400
Subject: [PATCH 4/6] fix(backend): require html or text in emails.create at
the type level (#9011)
---
.changeset/email-cr-fixes.md | 2 +
.../src/api/__tests__/EmailApi.test.ts | 34 +++++++++++++++
.../backend/src/api/endpoints/EmailApi.ts | 43 +++++++++++++------
3 files changed, 66 insertions(+), 13 deletions(-)
create mode 100644 .changeset/email-cr-fixes.md
diff --git a/.changeset/email-cr-fixes.md b/.changeset/email-cr-fixes.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/email-cr-fixes.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/packages/backend/src/api/__tests__/EmailApi.test.ts b/packages/backend/src/api/__tests__/EmailApi.test.ts
index f78154917e0..c02a8db753b 100644
--- a/packages/backend/src/api/__tests__/EmailApi.test.ts
+++ b/packages/backend/src/api/__tests__/EmailApi.test.ts
@@ -59,6 +59,40 @@ describe('EmailApi', () => {
expect(response.deliveredByClerk).toBe(true);
});
+ it('sends a transactional email with a text body', async () => {
+ server.use(
+ http.post(
+ 'https://api.clerk.test/v1/email',
+ validateHeaders(async ({ request }) => {
+ const body = await request.json();
+ expect(body).toEqual({
+ to: { address: 'admin@acme.com' },
+ from: { address: 'noreply@acme.com' },
+ subject: 'Hello',
+ text: 'hi',
+ });
+ return HttpResponse.json({
+ ...mockEmail,
+ body: null,
+ body_plain: 'hi',
+ });
+ }),
+ ),
+ );
+
+ const response = await apiClient.emails.create({
+ to: { address: 'admin@acme.com' },
+ from: { address: 'noreply@acme.com' },
+ subject: 'Hello',
+ text: 'hi',
+ });
+
+ expect(response.id).toBe('ema_123');
+ expect(response.body).toBeNull();
+ expect(response.bodyPlain).toBe('hi');
+ expect(response.status).toBe('queued');
+ });
+
it('sends a transactional email addressed by userId', async () => {
server.use(
http.post(
diff --git a/packages/backend/src/api/endpoints/EmailApi.ts b/packages/backend/src/api/endpoints/EmailApi.ts
index a4f8d35d18b..16a7960effb 100644
--- a/packages/backend/src/api/endpoints/EmailApi.ts
+++ b/packages/backend/src/api/endpoints/EmailApi.ts
@@ -55,6 +55,35 @@ type EmailRecipient =
name?: never;
};
+/**
+ * The body of the email. At least one of `html` and `text` must be provided; if
+ * both are provided, the `html` version takes precedence. Encoded as a union so
+ * that omitting both is a compile-time error rather than a server-side one.
+ */
+type EmailContent =
+ | {
+ /**
+ * The HTML body of the email. Takes precedence over `text` when both are
+ * provided.
+ */
+ html: string;
+ /**
+ * (Optional) The plain text body of the email.
+ */
+ text?: string;
+ }
+ | {
+ /**
+ * (Optional) The HTML body of the email. Takes precedence over `text`
+ * when both are provided.
+ */
+ html?: string;
+ /**
+ * The plain text body of the email.
+ */
+ text: string;
+ };
+
export type CreateEmailParams = {
/**
* The recipient of the email. Currently only a single recipient is supported.
@@ -75,19 +104,7 @@ export type CreateEmailParams = {
replyTo?: Mailbox;
subject: string;
-
- /**
- * The HTML body of the email. At least one of `html` and `text` must be
- * provided. If both are provided, the `html` version will take precedence.
- */
- html?: string;
-
- /**
- * The plain text body of the email. At least one of `html` and `text` must be
- * provided. If both are provided, the `html` version will take precedence.
- */
- text?: string;
-};
+} & EmailContent;
export class EmailApi extends AbstractAPI {
/**
From 51ca7ab1d17c0522765941778bbdc42010592eef Mon Sep 17 00:00:00 2001
From: Serge the Lion <8313760+cbnsndwch@users.noreply.github.com>
Date: Thu, 25 Jun 2026 23:38:53 -0400
Subject: [PATCH 5/6] Update changeset
Co-authored-by: Robert Soriano
---
.changeset/clerk-email-send.md | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/.changeset/clerk-email-send.md b/.changeset/clerk-email-send.md
index 2c500d23aba..7569e92c59c 100644
--- a/.changeset/clerk-email-send.md
+++ b/.changeset/clerk-email-send.md
@@ -2,4 +2,6 @@
'@clerk/backend': minor
---
-Add an experimental `clerkClient.emails.create()` method for sending a transactional email through the Clerk Backend API. Accepts `to`, `from`, optional `replyTo`, `subject`, and `html`/`text` content and returns the created `Email` resource. The `to` recipient is mutually exclusive: pass either `{ address }` (with an optional `name`) or `{ userId }`, in which case Clerk resolves that user's primary email address server-side. The underlying endpoint is internal and not yet generally available, so the method is marked `@experimental` and is subject to change; pin your SDK version if you rely on it.
+Add an experimental `clerkClient.emails.create()` method for sending transactional emails. It accepts address- or user-based recipients, supports optional `replyTo`, `subject`, and HTML and/or text content, and returns the created `Email` resource.
+
+This method is marked `@experimental` and may change in a future release.
From c00e980147772796ce140ede1a6e16c9c229edb1 Mon Sep 17 00:00:00 2001
From: cbnsndwch <8313760+cbnsndwch@users.noreply.github.com>
Date: Fri, 26 Jun 2026 03:43:41 +0000
Subject: [PATCH 6/6] chore: delete changeset file from merged stacked pr
---
.changeset/email-cr-fixes.md | 2 --
1 file changed, 2 deletions(-)
delete mode 100644 .changeset/email-cr-fixes.md
diff --git a/.changeset/email-cr-fixes.md b/.changeset/email-cr-fixes.md
deleted file mode 100644
index a845151cc84..00000000000
--- a/.changeset/email-cr-fixes.md
+++ /dev/null
@@ -1,2 +0,0 @@
----
----