Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
d15e134
docs(workflows): add the transactional email API page
mayteio Oct 24, 2026
810cbed
docs(workflows): document the single send route, email status, and th…
mayteio Oct 29, 2026
80fa5f8
docs(workflows): fix vale findings on the transactional email page
mayteio Oct 29, 2026
ded4a9a
docs(workflows): address SDK review on the transactional email page
mayteio Oct 29, 2026
3fed7e4
docs(workflows): split transactional emails into overview and platfor…
mayteio Oct 29, 2026
b087284
docs(workflows): add a quick start and the Transactional section to t…
mayteio Oct 29, 2026
d55c648
docs(workflows): mention the per-send max_retries in Python
mayteio Oct 29, 2026
38f138c
docs(workflows): send template_id and distinct_id at the top of the body
mayteio Oct 30, 2026
76ad37a
docs(workflows): send content from your code on the transactional ema…
Silthus Oct 31, 2026
831841b
docs(workflows): list every reserved header and the header and tag li…
Silthus Nov 1, 2026
b42086e
docs(workflows): say that a header name has no spaces
Silthus Nov 1, 2026
acd4a4b
docs(workflows): say that a header name has visible ASCII characters …
Silthus Nov 1, 2026
2502eb6
docs(workflows): say what makes a code send the same request, and wha…
Silthus Nov 1, 2026
673346e
docs(workflows): list capture_engagement on the template send, and sp…
Silthus Nov 1, 2026
2885c5b
Merge branch 'master' into docs/transactional-email-api-code-form
Silthus Nov 2, 2026
3f33a25
docs(workflows): cut cc, bcc, headers, tags and attachments from the …
Silthus Nov 2, 2026
7295a66
docs(workflows): match the code form to the first send release
Silthus Nov 2, 2026
2610e8d
docs(workflows): name the first release's field errors and the sender…
Silthus Nov 2, 2026
5cffbd0
docs(workflows): link transactional emails to your users with the ide…
Silthus Nov 3, 2026
51f427b
docs(workflows): say which signup email can leave out the distinct ID
Silthus Nov 3, 2026
c42c7c3
docs(workflows): match the link-emails section to the docs style rules
Silthus Nov 3, 2026
01f7e5f
docs(workflows): fill the SDK examples with the reader's token and host
Silthus Nov 4, 2026
86a0e43
docs(workflows): name the transactional_email scopes for sends and st…
Silthus Nov 5, 2026
8d35205
docs(workflows): pick Transactional email in the key scopes list
Silthus Nov 5, 2026
54968e2
docs(workflows): make a template transactional with the transactional…
Silthus Nov 5, 2026
bd17219
docs(workflows): say to retry a status read right after a send
Silthus Nov 5, 2026
054a8a4
docs(workflows): Python code sends start at 7.84.0
Silthus Nov 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 61 additions & 4 deletions contents/docs/workflows/transactional-emails/api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ Send a JSON body with these fields:
| `template_id` | Yes | The ID of the transactional template to send. |
| `to` | Yes | The email address of the recipient. One address for each request. |
| `message_data` | No | An object with the values for the template's `{{ variables.* }}` placeholders. For example, `{"reset_link": "https://example.com/reset"}` fills `{{ variables.reset_link }}`. The template can't read person properties, so pass all the data that the email needs here. |
| `distinct_id` | No | The distinct ID of the recipient's person. PostHog records the send and its [engagement events](/docs/workflows/engagement-events) against this person. If you leave it out, PostHog uses the email address as the distinct ID, which might not match the person in your product. We recommend that you always send it. |
| `distinct_id` | No | The distinct ID of the recipient's person. PostHog records the send and its [engagement events](/docs/workflows/engagement-events) against this person. Pass the distinct ID that you pass to `identify()`. See [link emails to your users](#link-emails-to-your-users). |

In the template, use the same names that you send in `message_data`:

Expand Down Expand Up @@ -74,7 +74,64 @@ curl -X POST "https://us.posthog.com/api/projects/@current/messaging/send/email/

The older route, `POST /api/projects/@current/messaging_templates/:template_id/send/`, still works. It takes the template ID in the URL and the distinct ID as `identifiers.distinct_id`.

When a send fails, the response carries a `code`. See [errors](/docs/workflows/transactional-emails#errors). After a `503`, PostHog might have queued the email. Retry with the same `Idempotency-Key` rather than sending the email through another provider, or the recipient can get it twice.
When a send fails, the response carries a `code`. See [errors](/docs/workflows/transactional-emails#errors) and the [checks for content from your code](#checks-for-content-from-your-code).

### `delivery_unavailable`

A `503` with the code `delivery_unavailable` means that PostHog couldn't confirm that it queued the email. PostHog might still send it. Retry with the same `Idempotency-Key` rather than sending the email through another provider, or the recipient can get it twice. The SDKs retry this on their own.

## Link emails to your users

Pass the distinct ID that you pass to `identify()`, usually the ID your app gives that user, as `distinct_id`. PostHog then records the send, and its [engagement events](/docs/workflows/engagement-events) such as sent, opened, and clicked, against that user's person, next to their product events.

- **If you leave it out,** PostHog records the email against a person whose distinct ID is the recipient's email address. Unless your app identifies users by their email address, that's a separate person from your user.
- **For someone who isn't a user yet,** such as an invite or an email address check before the account exists, there's no distinct ID to pass. Leave `distinct_id` out.

## Send content from your code

You can also send the subject and the body from your code, without a template in PostHog. Use this when your code renders the email. For a [React Email](https://react.email) component, render it to HTML with `render` from `@react-email/render`, and send the result as `html`. Name the email with `template_key`, and send the content in the body. Use the same `POST` route and headers as for a [template send](#send-an-email).

| Field | Required | Description |
| --- | --- | --- |
| `template_key` | Yes | A name for this email, such as `password-reset`. It has at most 400 letters, digits, hyphens (`-`), and underscores (`_`). PostHog groups the metrics and the sends of this email under the key, so use the same key every time you send this email. |
| `to` | Yes | The email address of the recipient. |
| `distinct_id` | No | The distinct ID of the recipient's person, as for a template send. See [link emails to your users](#link-emails-to-your-users). |
| `subject` | Yes | The subject of the email. |
| `html` | One of `html` and `text` | The HTML body. PostHog sends it as you wrote it and renders no placeholders. |
| `text` | One of `html` and `text` | The plain-text body. If you leave it out, PostHog makes it from `html`. |
| `from` | No | The sender, as `[email protected]` or `Hedgebox <[email protected]>`, on a domain that your project verified. If you leave it out, PostHog uses the project's default sender. |
| `reply_to` | No | A list of up to 10 addresses that replies go to, without a display name. |
| `capture_engagement` | No | Whether PostHog captures the email's [engagement events](/docs/workflows/engagement-events). The default is `true`. Send `false` to turn capture off for this email. If the project's **Capture email engagement events** setting is off, PostHog captures nothing, whatever you send. |

A request has `template_id` or `template_key`, not both. Content from your code takes no `message_data`, because PostHog renders nothing. A template send takes none of `subject`, `html`, `text`, `from`, `reply_to`, and `capture_engagement`, because the template uses its own settings. PostHog returns a `400` that names the field in `attr`.

If the `to` address is on the [suppression list](/docs/workflows/opt-outs#the-suppression-list), PostHog sends nothing and returns `recipient_suppressed`.

The response is the same `202` as for a template send. A retry with the same `Idempotency-Key` is the [same request](/docs/workflows/transactional-emails#retry-safely-with-an-idempotency-key) when every field in the table above has the same value as in the first request. PostHog also compares the sender that it chose: if you leave out `from` and the project's default sender changes before the retry, PostHog returns `idempotency_key_reused`.

```bash
curl -X POST "https://us.posthog.com/api/projects/@current/messaging/send/email/" \
-H "Authorization: Bearer $POSTHOG_PROJECT_SECRET_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: password-reset-8f14e45f" \
-d '{
"template_key": "password-reset",
"to": "[email protected]",
"distinct_id": "user_123",
"subject": "Reset your password",
"html": "<p>Hi Jane,</p><p><a href=\"https://example.com/reset?token=abc123\">Reset your password</a>.</p>",
"from": "Hedgebox <[email protected]>"
}'
```

### Checks for content from your code

These checks apply to content from your code, in addition to the [errors](/docs/workflows/transactional-emails#errors) of every send. PostHog sends nothing when a check fails.

| Status | Code | Cause | What to do |
| --- | --- | --- | --- |
| `400` | A code for the field, such as `required` or `invalid` | A field is missing or breaks a rule above, or PostHog doesn't take the field. The `type` is `validation_error`, and `attr` names the field, such as `subject`. | Fix the field. If the field comes from a newer SDK, update PostHog or leave the field out. |
| `400` | `sender_not_verified` | No verified sender of the project can send from the `from` address. Or the request has no `from`, and the project has no verified default sender. | Send from an address on a verified domain, or [set a default sender](/docs/workflows/configure-channels) and leave out `from`. |

## Check an email's status

Expand All @@ -84,7 +141,7 @@ To find out what happened to an email, read its status with the `message_id` fro
GET https://us.posthog.com/api/projects/@current/messaging/emails/:message_id/
```

Send the same `Authorization` header as for a send. The key needs the `hog_flow:read` or `hog_flow:write` scope. Only a project secret API key can read a status.
Send the same `Authorization` header as for a send. The key needs the `transactional_email:read` or `transactional_email:write` scope. A key with a `hog_flow` scope still works. Only a project secret API key can read a status.

A found email returns `200`:

Expand All @@ -100,7 +157,7 @@ A found email returns `200`:

See [statuses and reasons](/docs/workflows/transactional-emails#check-an-emails-status) for what each value means.

A `404` with the code `not_found` means the status isn't there yet, is older than 30 days, or the ID is wrong. See [when a status isn't found](/docs/workflows/transactional-emails#when-a-status-isnt-found). It returns `401` when the request has no project secret API key, `403` when the key doesn't have a Workflow scope, and `429` when you read too often.
A `404` with the code `not_found` right after a send means the status isn't there yet: a new send can take a few seconds to get its first status, so retry after a short wait. Later, a `404` means the email is older than 30 days or the ID is wrong. See [when a status isn't found](/docs/workflows/transactional-emails#when-a-status-isnt-found). It returns `401` when the request has no project secret API key, `403` when the key doesn't have a Workflow scope, and `429` when you read too often.

Each project secret API key can read 600 statuses per minute and 20,000 per hour. All the keys of one project share the same limits in total. Reads have their own limits, so they don't use up the limits for sends.

Expand Down
12 changes: 6 additions & 6 deletions contents/docs/workflows/transactional-emails/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ To send an email template you already have, open it in the **Templates** tab, op

With the [PostHog MCP server](/docs/model-context-protocol) in your coding agent, ask the agent to set up a transactional email. It can design the email from your brand, make it transactional, and write the code that sends it. It asks you to confirm before it makes a template transactional, and you create the API key yourself.

You can also make a template transactional with the API: call `POST /api/projects/:project_id/messaging_templates/:template_id/make_transactional/` with a [personal API key](/docs/api/personal-api-keys) that has the `hog_flow:write` scope.
You can also make a template transactional with the API: call `POST /api/projects/:project_id/messaging_templates/:template_id/make_transactional/` with a [personal API key](/docs/api/personal-api-keys) that has the `transactional_email:write` scope. Keys with `hog_flow:write` still work.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 · Keep the Workflow write scope for making templates transactional

A personal API key with only transactional_email:write receives 403 from make_transactional. MessageTemplatesViewSet still declares this action under scope_object = "hog_flow", so it requires hog_flow:write; the new email scope authorizes sending and status reads, not template conversion. Keep hog_flow:write in this instruction.


### Edit a transactional email

Expand All @@ -58,16 +58,16 @@ For emails with sign-in codes, reset links, or other secrets, turn on **Protect

## Create an API key

Send transactional emails with a [project secret API key](/docs/api/project-secret-api-keys) that has the `hog_flow:write` scope. The same key can also [read the status](#check-an-emails-status) of each email it sends.
Send transactional emails with a [project secret API key](/docs/api/project-secret-api-keys) that has the `transactional_email:write` scope. Keys with `hog_flow:write` still work. The same key can also [read the status](#check-an-emails-status) of each email it sends.

1. Go to [**Project secret API keys**](https://app.posthog.com/settings/project-secret-api-keys) in your project settings.
2. Click **Create project secret API key** and give the key a label.
3. In the scopes list, set **Workflow** to **Write**.
3. In the scopes list, set **Transactional email** to **Write**.
4. Copy the key. It starts with `phs_`, and you can't see it again after you leave the page.

A project secret API key with this scope can send transactional emails and read their status, and nothing else. It can't read or change your templates, and it can't use any other part of the Workflows API. Keep the key on your server. Never put it in client-side code.

To give a service a key that can read statuses but not send, set **Workflow** to **Read** instead.
To give a service a key that can read statuses but not send, set **Transactional email** to **Read** instead.

## Send an email

Expand All @@ -76,7 +76,7 @@ Your backend sends each email with one call. A send needs:
- **The template ID.** The template's **API** tab shows it.
- **The recipient.** One email address for each send.
- **The template data.** The values for the template's `{{ variables.* }}` placeholders. The template can't read person properties, so pass all the data that the email needs.
- **The recipient's distinct ID.** PostHog records the send and its [engagement events](/docs/workflows/engagement-events) against this person. Without it, PostHog uses the email address as the distinct ID, which might not match the person in your product. We recommend that you always send it.
- **The recipient's distinct ID.** The distinct ID that you pass to `identify()`. PostHog records the send and its [engagement events](/docs/workflows/engagement-events) against this person. Without it, PostHog records the email against a person whose distinct ID is the email address, which is a separate person from your user unless you identify users by their email address. Leave it out for someone who isn't a user yet, such as an invite.
- **An idempotency key.** It makes a retry safe. See [retry safely](#retry-safely-with-an-idempotency-key).

In the template, use the same names that you send in the template data:
Expand Down Expand Up @@ -129,7 +129,7 @@ Use `code` in your error handling. The text in `detail` can change.
| `400` | `marketing_template` | The template is in a marketing category. | Move the template to a transactional category, or remove its category. |
| `400` | `transactional_email_paused` | The workflow that sends this template isn't active, or it was deleted. | Don't retry. A retry gets the same error. |
| `401` | `not_authenticated` or `authentication_failed` | The `Authorization` header is missing, or the key isn't valid. | Check that you send a valid project secret API key. |
| `403` | `permission_denied` | The key doesn't have the `hog_flow:write` scope, or the key belongs to a different project. | Use a key from this project with the correct scope. |
| `403` | `permission_denied` | The key doesn't have the `transactional_email:write` scope, or the key belongs to a different project. | Use a key from this project with the correct scope. |
| `404` | `not_found` | The template doesn't exist in this project, or transactional emails aren't turned on for the project. | Check the template ID and the project. |
| `409` | `idempotency_key_in_flight` | A request with the same `Idempotency-Key` is still in progress. | Retry with the same key after a few seconds. |
| `422` | `idempotency_key_reused` | The `Idempotency-Key` was already used for a different request. | Use a new key for a new email. |
Expand Down
49 changes: 46 additions & 3 deletions contents/docs/workflows/transactional-emails/node.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,15 @@ Reuse the PostHog client your application already creates, and set `secretKey` t
```js
import { PostHog } from "posthog-node"

const posthog = new PostHog("<ph_project_api_key>", {
host: "https://us.i.posthog.com",
const posthog = new PostHog("<ph_project_token>", {
host: "<ph_client_api_host>",
secretKey: process.env.POSTHOG_PROJECT_SECRET_API_KEY,
// This client only sends email, so it doesn't poll flag definitions with the secret key
enableLocalEvaluation: false,
})
```

- **Local evaluation:** The client starts polling flag definitions with the secret key when it starts. A key with only the **Workflow** scope can't do that, so a client that only sends email needs `enableLocalEvaluation: false`. If your application [evaluates flags locally](/docs/feature-flags/local-evaluation) with the same client, give the key the **Feature flag** read scope too, and leave local evaluation on.
- **Local evaluation:** The client starts polling flag definitions with the secret key when it starts. A key with only the **Transactional email** scope can't do that, so a client that only sends email needs `enableLocalEvaluation: false`. If your application [evaluates flags locally](/docs/feature-flags/local-evaluation) with the same client, give the key the **Feature flag** read scope too, and leave local evaluation on.
- **Host:** On PostHog Cloud, the client sends email to the app host of your region, even when `host` is an ingestion host such as `us.i.posthog.com`. For any other host, such as a reverse proxy or a self-hosted instance, set `appHost` to your PostHog app URL. The client refuses to send the secret key to a host that might be a proxy.

## Send an email
Expand Down Expand Up @@ -58,6 +58,49 @@ Each request times out after 15 seconds. The client retries network errors, rate

To change this, set `emailRequestTimeoutMs` and `emailRequestMaxRetries` on the client, or `maxRetries` on one send. When a job queue already retries the send, set the retries to `0` and pass an `idempotencyKey` that identifies the email.

## Link emails to your users

Pass the distinct ID that you pass to `identify()`, usually the ID your app gives that user, as `distinctId`. PostHog then records the send, and its [engagement events](/docs/workflows/engagement-events) such as sent, opened, and clicked, against that user's person, next to their product events.

- **If you leave it out,** PostHog records the email against a person whose distinct ID is the recipient's email address. Unless your app identifies users by their email address, that's a separate person from your user.
- **For someone who isn't a user yet,** such as an invite or an email address check before the account exists, there's no distinct ID to pass. Leave `distinctId` out.

## Send content from your code

From version 5.62.0, `send` also takes content that your code rendered, without a template in PostHog. Name the email with `templateKey`, and pass `subject` and `html`, `text`, or both. PostHog sends the content as you wrote it.

For a [React Email](https://react.email) component, render it to HTML with `render` from `@react-email/render`, and pass the result as `html`:

```jsx
import { render } from "@react-email/render"
import { PasswordResetEmail } from "./emails/password-reset"

export async function sendPasswordReset({ userId, email, firstName, resetLink, resetRequestId }) {
const html = await render(<PasswordResetEmail firstName={firstName} resetLink={resetLink} />)
const { data, error } = await posthog.emails.send({
to: email,
templateKey: "password-reset",
subject: "Reset your password",
html,
from: "Hedgebox <[email protected]>",
distinctId: userId,
idempotencyKey: `password-reset-${resetRequestId}`,
})
if (error) {
throw error
}
return data.id
}
```

- **Key:** `templateKey` names the email, such as `password-reset`. PostHog groups the email's metrics and sends under it, so use the same key every time you send this email.
- **Text:** If you leave out `text`, PostHog makes it from `html`.
- **Sender:** `from` needs an address on a domain that your project verified. Without it, PostHog uses the project's default sender. `replyTo` takes one address or a list.
- **Engagement:** PostHog captures the email's engagement events unless you pass `captureEngagement: false`.
- **One form per send:** A send has `templateId` or `templateKey`, not both. Content from your code takes no `templateData`, because PostHog renders nothing.

See [content from your code](/docs/workflows/transactional-emails/api#send-content-from-your-code) for each field and its checks.

## Check an email's status

```js
Expand Down
Loading
Loading