From d15e134fbe5342a7a9485b7a64ab8230eb2ed5e6 Mon Sep 17 00:00:00 2001 From: Harley Alexander <43975092+mayteio@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:38:10 +0100 Subject: [PATCH 01/26] docs(workflows): add the transactional email API page Document how to make a Workflows email template transactional and send it with POST /api/projects/:project_id/messaging_templates/:template_id/send/. The page covers the API key, the request and response, idempotency keys, error codes, rate limits, and how transactional emails behave. The page is marked beta. Co-Authored-By: Claude Opus 5.5 Generated-By: PostHog Desktop Task-Id: a0274fa0-d9be-48bf-b601-f153decf241d --- .../docs/workflows/transactional-emails.mdx | 246 ++++++++++++++++++ src/navs/index.js | 9 + 2 files changed, 255 insertions(+) create mode 100644 contents/docs/workflows/transactional-emails.mdx diff --git a/contents/docs/workflows/transactional-emails.mdx b/contents/docs/workflows/transactional-emails.mdx new file mode 100644 index 000000000000..ba1f209e7f5d --- /dev/null +++ b/contents/docs/workflows/transactional-emails.mdx @@ -0,0 +1,246 @@ +--- +title: Send transactional emails +sidebar: Docs +showTitle: true +--- + + + +Transactional emails are a new feature. Things might change or break while we work on them. + + + +Transactional emails are one-off emails that a person triggers in your product, like password resets, sign-in codes, and receipts. You design the email as a [message template](/docs/workflows/library) in Workflows. Your backend then sends it to one recipient with one API call. + +Each transactional template has its own workflow behind it. PostHog doesn't show this workflow on the **Workflows** tab. It shows the workflow's data on the template instead: + +- **Metrics** shows the metrics for the template's sends. +- **Sends** lists each send, with its log messages. +- **Versions** keeps each version of the email that you publish. + +## Make a template transactional + +1. In Workflows, open the [**Templates** tab](https://app.posthog.com/workflows/library) and choose **Email templates**. +2. Open the template you want to send, or create one. +3. Open the **API** tab and click **Make transactional**. + +The template needs these before you can make it transactional: + +- **A subject and a body.** Save any changes first. +- **A verified email sender.** PostHog uses your project's default sender. You can change the sender later. To set up a sender, see [configure a channel](/docs/workflows/configure-channels). +- **No marketing category.** Transactional emails ignore opt-outs, so PostHog doesn't send a template that is in a marketing [message category](/docs/workflows/opt-outs#message-categories). + + + +A template stays transactional after you make it transactional. If you still use the template for other emails, click **Duplicate** in the template's menu first, and make the copy transactional. + + + +You can also make a template transactional over 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. + +### Edit a transactional template + +- **Sender:** The **Design** tab shows who the email comes from. Click **Edit sender** to choose a different verified sender, or to set a display name, a from address, or a reply-to address. The from address must be on the sender's verified domain. To use more than one reply-to address, separate them with commas. +- **Drafts:** Your edits save as a draft. Sends use the live version of the email until you click **Publish**. Each publish adds a version on the **Versions** tab. To remove your edits, use **Discard draft**. +- **Checks:** PostHog doesn't publish a change that would stop the template from sending, such as an empty subject or body, or a marketing category. + +### Protect sensitive data + +For emails with sign-in codes, reset links, or other secrets, turn on **Protect sensitive data** on the **API** tab. PostHog then keeps no copy of the body or the filled-in subject of each email, and clicked links keep only the site address. You can turn this setting on or off at any time. + +## 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. + +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**. +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 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. + +## Send an email + +``` +POST https://us.posthog.com/api/projects/@current/messaging_templates/:template_id/send/ +``` + +- **Host:** Use `https://us.posthog.com` or `https://eu.posthog.com`, the host where you log in to PostHog. Don't use an ingestion host like `us.i.posthog.com`. If you self-host PostHog, use the URL of your instance. +- **Project:** `@current` is the project that the key belongs to. You can use your project ID instead. +- **Template ID:** The example on the template's **API** tab includes the full URL with the template ID. + +### Request + +Send these headers: + +| Header | Value | +| --- | --- | +| `Authorization` | `Bearer` followed by your project secret API key | +| `Content-Type` | `application/json` | +| `Idempotency-Key` | Optional. A unique key for this email. See [retry safely](#retry-safely-with-an-idempotency-key). | + +Send a JSON body with these fields: + +| Field | Required | Description | +| --- | --- | --- | +| `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. | +| `identifiers.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. | + +In the template, use the same names that you send in `message_data`: + +```liquid +Hi {{ variables.first_name }}, use this link to reset your password: {{ variables.reset_link }} +``` + +### Response + +A successful request returns `202 Accepted`: + +```json +{ + "message_id": "01963d8a-6f2c-7d4e-9b1a-3c5e8f2a7b10", + "replayed": false +} +``` + +A `202` means that PostHog queued the email. It doesn't mean that the email was delivered. To see what happened to each email, open the template's **Sends** tab. + +`replayed` is `true` when an earlier request with the same `Idempotency-Key` already queued this email. In that case, PostHog sent no new email, and `message_id` is the ID of the first send. + +### Examples + + + +```bash file=cURL +curl -X POST "https://us.posthog.com/api/projects/@current/messaging_templates//send/" \ + -H "Authorization: Bearer $POSTHOG_PROJECT_SECRET_API_KEY" \ + -H "Content-Type: application/json" \ + -H "Idempotency-Key: password-reset-8f14e45f" \ + -d '{ + "to": "jane@example.com", + "identifiers": { "distinct_id": "user_123" }, + "message_data": { "first_name": "Jane", "reset_link": "https://example.com/reset?token=abc123" } + }' +``` + +```python file=Python +import os + +import requests + +POSTHOG_HOST = "https://us.posthog.com" +TEMPLATE_ID = "" + +def send_password_reset(user_id: str, email: str, first_name: str, reset_link: str, reset_request_id: str) -> str: + response = requests.post( + f"{POSTHOG_HOST}/api/projects/@current/messaging_templates/{TEMPLATE_ID}/send/", + headers={ + "Authorization": f"Bearer {os.environ['POSTHOG_PROJECT_SECRET_API_KEY']}", + # Send the same key again when you retry this email + "Idempotency-Key": f"password-reset-{reset_request_id}", + }, + json={ + "to": email, + "identifiers": {"distinct_id": user_id}, + "message_data": {"first_name": first_name, "reset_link": reset_link}, + }, + timeout=10, + ) + response.raise_for_status() + return response.json()["message_id"] +``` + +```js file=Node.js +const POSTHOG_HOST = "https://us.posthog.com" +const TEMPLATE_ID = "" + +export async function sendPasswordReset({ userId, email, firstName, resetLink, resetRequestId }) { + const response = await fetch(`${POSTHOG_HOST}/api/projects/@current/messaging_templates/${TEMPLATE_ID}/send/`, { + method: "POST", + headers: { + Authorization: `Bearer ${process.env.POSTHOG_PROJECT_SECRET_API_KEY}`, + "Content-Type": "application/json", + // Send the same key again when you retry this email + "Idempotency-Key": `password-reset-${resetRequestId}`, + }, + body: JSON.stringify({ + to: email, + identifiers: { distinct_id: userId }, + message_data: { first_name: firstName, reset_link: resetLink }, + }), + }) + if (!response.ok) { + const error = await response.json() + throw new Error(`PostHog refused the email: ${response.status} ${error.code}`) + } + const { message_id } = await response.json() + return message_id +} +``` + + + +Support for transactional emails in the Python and Node.js SDKs is coming. Until then, call the API directly. + +## Retry safely with an idempotency key + +Without an `Idempotency-Key`, a retry after a timeout or a `503` can send the email twice. To prevent this, send a key that identifies this one email, such as the ID of the password reset request. A key is 1 to 255 visible ASCII characters, with no spaces. + +- **Same key, same request:** PostHog sends no new email. It returns `202` with the `message_id` of the first send and `"replayed": true`. PostHog remembers a key for 24 hours after it queues the email. +- **Same key, different request:** PostHog returns `422` with the code `idempotency_key_reused`. A request is the same when it has the same template, `to`, `distinct_id`, and `message_data`. +- **First request not finished:** PostHog returns `409` with the code `idempotency_key_in_flight`. Wait a few seconds, then retry with the same key. +- **Refused request:** When PostHog refuses a send, for example with `recipient_suppressed` or `quota_limited`, the key stays unused. You can send the same key again later. + +Keys are separate for each project. + +## Errors + +An error response has this shape: + +```json +{ + "type": "invalid_request", + "code": "recipient_suppressed", + "detail": "The recipient is on this project's suppression list after a hard bounce or a spam complaint, so the email can't be sent. Remove the address from the suppression list if it can receive email again.", + "attr": null +} +``` + +Use `code` in your error handling. The text in `detail` can change. + +| Status | `code` | What it means | What to do | +| --- | --- | --- | --- | +| `400` | Varies, with `"type": "validation_error"` | The body isn't valid. For example, `to` is missing or isn't an email address. `attr` names the field. | Fix the request. | +| `400` | `invalid_recipient` | The `to` address contains `{` or `}`. | Send a plain email address. | +| `400` | `invalid_idempotency_key` | The `Idempotency-Key` header isn't 1 to 255 visible ASCII characters. | Use a valid key. | +| `400` | `template_not_transactional` | The template isn't transactional. | [Make the template transactional](#make-a-template-transactional). | +| `400` | `template_not_sendable` | The template has no subject or no body. | Add them and publish the template. | +| `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. | +| `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. | +| `422` | `recipient_suppressed` | The address is on your project's [suppression list](/docs/workflows/opt-outs#the-suppression-list) after a hard bounce or a spam complaint. | Don't retry. Remove the address from the suppression list only if you know that it can receive email again. | +| `422` | `sending_paused` | Sending is paused for this transactional email because of its bounce or complaint rate. | Check the **Reputation** tab in Workflows. | +| `422` | `sending_suspended` | Email sending is suspended for your project. | Check the **Reputation** tab in Workflows. | +| `429` | `throttled` | You sent too many requests. | Wait for the number of seconds in the `Retry-After` header, then retry. | +| `429` | `quota_limited` | Your project is over its email quota. | Check your usage and [billing limits](/docs/billing/limits-alerts). A retry gets the same error until the project is under its quota. | +| `503` | `delivery_unavailable` | PostHog couldn't confirm that it queued the email. | Retry with backoff. Send the same `Idempotency-Key` so that the email doesn't go out twice. | + +If you make a template transactional or save it without a verified sender, PostHog returns `400` with the code `sender_unavailable`. + +### Rate limits + +Each project secret API key can send 600 requests per minute and 20,000 requests per hour. All the keys of one project share the same limits in total. A request over a limit gets a `429` with the code `throttled`. + +## How transactional emails behave + +- **No opt-outs or frequency caps.** Transactional emails ignore marketing [opt-outs](/docs/workflows/opt-outs) and frequency caps. Use them only for emails that a person needs, never for marketing. +- **Suppression still applies.** PostHog doesn't send to an address on your suppression list. +- **Billing and quotas.** Transactional emails are billed and quota limited like other workflow emails. See [pricing](/pricing) and [billing limits](/docs/billing/limits-alerts). +- **Sending allowance.** Transactional emails count toward your project's [sending allowance](/docs/workflows/sending-reputation). When your project reaches its hourly or daily cap, emails wait in the queue until the allowance frees up. If a transactional email gets too many bounces or complaints, PostHog can pause its sending, and sends return `sending_paused`. +- **Engagement events.** If you [capture email engagement events](/docs/workflows/engagement-events), PostHog records them for transactional emails against the person in `identifiers.distinct_id`. +- **Where to see sends.** The template's **Sends** tab lists each send with its log messages. The **Metrics** tab shows their metrics. Each send also appears on the recipient's person. diff --git a/src/navs/index.js b/src/navs/index.js index b459f2877c9b..68b02cd2ee3e 100644 --- a/src/navs/index.js +++ b/src/navs/index.js @@ -8502,6 +8502,15 @@ export const docsMenu = { icon: 'IconSend', color: 'orange', }, + { + name: 'Transactional emails', + }, + { + name: 'Send transactional emails', + url: '/docs/workflows/transactional-emails', + icon: 'IconLetter', + color: 'blue', + }, { name: 'Channels', }, From 810cbede532e63f46565832b454c8f7ceb95bbe4 Mon Sep 17 00:00:00 2001 From: Harley Alexander <43975092+mayteio@users.noreply.github.com> Date: Thu, 29 Oct 2026 11:26:52 +0000 Subject: [PATCH 02/26] docs(workflows): document the single send route, email status, and the SDKs - Send through `POST /messaging/send/email/` with `template.id`. The template route still works. - Read an email's status with `GET /messaging/emails/:message_id/`: statuses, reasons, 404 cases, scopes and read limits. - Send and read with the Python and Node.js SDKs. - Add `recipient_unreachable` to the errors table. Co-Authored-By: Claude Opus 5.5 Generated-By: PostHog Desktop Task-Id: e72a2c10-f145-4f7c-8754-a4fcc06a18be --- .../docs/workflows/transactional-emails.mdx | 186 +++++++++++++----- 1 file changed, 139 insertions(+), 47 deletions(-) diff --git a/contents/docs/workflows/transactional-emails.mdx b/contents/docs/workflows/transactional-emails.mdx index ba1f209e7f5d..d32164986846 100644 --- a/contents/docs/workflows/transactional-emails.mdx +++ b/contents/docs/workflows/transactional-emails.mdx @@ -50,24 +50,26 @@ 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. +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. 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**. 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 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. +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. ## Send an email ``` -POST https://us.posthog.com/api/projects/@current/messaging_templates/:template_id/send/ +POST https://us.posthog.com/api/projects/@current/messaging/send/email/ ``` - **Host:** Use `https://us.posthog.com` or `https://eu.posthog.com`, the host where you log in to PostHog. Don't use an ingestion host like `us.i.posthog.com`. If you self-host PostHog, use the URL of your instance. - **Project:** `@current` is the project that the key belongs to. You can use your project ID instead. -- **Template ID:** The example on the template's **API** tab includes the full URL with the template ID. +- **Template ID:** Send it in the body as `template.id`. The template's **API** tab shows it. ### Request @@ -83,6 +85,7 @@ Send a JSON body with these fields: | Field | Required | Description | | --- | --- | --- | +| `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. | | `identifiers.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. | @@ -104,7 +107,7 @@ A successful request returns `202 Accepted`: } ``` -A `202` means that PostHog queued the email. It doesn't mean that the email was delivered. To see what happened to each email, open the template's **Sends** tab. +A `202` means that PostHog queued the email. It doesn't mean that the email was delivered. To see what happened to the email, [check its status](#check-an-emails-status) with its `message_id`, or open the template's **Sends** tab. `replayed` is `true` when an earlier request with the same `Idempotency-Key` already queued this email. In that case, PostHog sent no new email, and `message_id` is the ID of the first send. @@ -113,11 +116,12 @@ A `202` means that PostHog queued the email. It doesn't mean that the email was ```bash file=cURL -curl -X POST "https://us.posthog.com/api/projects/@current/messaging_templates//send/" \ +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": { "id": "" }, "to": "jane@example.com", "identifiers": { "distinct_id": "user_123" }, "message_data": { "first_name": "Jane", "reset_link": "https://example.com/reset?token=abc123" } @@ -127,61 +131,67 @@ curl -X POST "https://us.posthog.com/api/projects/@current/messaging_templates/< ```python file=Python import os -import requests +from posthog import Posthog +from posthog.emails import EmailError -POSTHOG_HOST = "https://us.posthog.com" -TEMPLATE_ID = "" +posthog = Posthog( + "", + host="https://us.i.posthog.com", + secret_key=os.environ["POSTHOG_PROJECT_SECRET_API_KEY"], +) def send_password_reset(user_id: str, email: str, first_name: str, reset_link: str, reset_request_id: str) -> str: - response = requests.post( - f"{POSTHOG_HOST}/api/projects/@current/messaging_templates/{TEMPLATE_ID}/send/", - headers={ - "Authorization": f"Bearer {os.environ['POSTHOG_PROJECT_SECRET_API_KEY']}", - # Send the same key again when you retry this email - "Idempotency-Key": f"password-reset-{reset_request_id}", - }, - json={ - "to": email, - "identifiers": {"distinct_id": user_id}, - "message_data": {"first_name": first_name, "reset_link": reset_link}, - }, - timeout=10, - ) - response.raise_for_status() - return response.json()["message_id"] + try: + result = posthog.emails.send( + to=email, + template_id="", + template_data={"first_name": first_name, "reset_link": reset_link}, + distinct_id=user_id, + # Identifies this one email, so a retry never sends it twice + idempotency_key=f"password-reset-{reset_request_id}", + ) + except EmailError as error: + raise RuntimeError(f"PostHog refused the email: {error.code}") from error + return result.id ``` ```js file=Node.js -const POSTHOG_HOST = "https://us.posthog.com" -const TEMPLATE_ID = "" +import { PostHog } from "posthog-node" + +const posthog = new PostHog("", { + host: "https://us.i.posthog.com", + secretKey: process.env.POSTHOG_PROJECT_SECRET_API_KEY, +}) export async function sendPasswordReset({ userId, email, firstName, resetLink, resetRequestId }) { - const response = await fetch(`${POSTHOG_HOST}/api/projects/@current/messaging_templates/${TEMPLATE_ID}/send/`, { - method: "POST", - headers: { - Authorization: `Bearer ${process.env.POSTHOG_PROJECT_SECRET_API_KEY}`, - "Content-Type": "application/json", - // Send the same key again when you retry this email - "Idempotency-Key": `password-reset-${resetRequestId}`, - }, - body: JSON.stringify({ - to: email, - identifiers: { distinct_id: userId }, - message_data: { first_name: firstName, reset_link: resetLink }, - }), + const { data, error } = await posthog.emails.send({ + to: email, + templateId: "", + templateData: { first_name: firstName, reset_link: resetLink }, + distinctId: userId, + // Identifies this one email, so a retry never sends it twice + idempotencyKey: `password-reset-${resetRequestId}`, }) - if (!response.ok) { - const error = await response.json() - throw new Error(`PostHog refused the email: ${response.status} ${error.code}`) + if (error) { + throw new Error(`PostHog refused the email: ${error.code}`) } - const { message_id } = await response.json() - return message_id + return data.id } ``` -Support for transactional emails in the Python and Node.js SDKs is coming. Until then, call the API directly. +The older route, `POST /api/projects/@current/messaging_templates/:template_id/send/`, takes the same body without `template` and still works. + +### Send with an SDK + +The [Python SDK](/docs/libraries/python) from version 7.81.0 and the [Node.js SDK](/docs/libraries/node) from version 5.61.0 send transactional emails with `emails.send`. + +- **Key:** Set `secret_key` in Python or `secretKey` in Node.js to your project secret API key. The client still captures events with your project API key. +- **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 `app_host` in Python or `appHost` in Node.js to your PostHog app URL. The client refuses to send the secret key to a host that might be a proxy. +- **Local evaluation:** The client also uses the secret key to [evaluate feature flags locally](/docs/feature-flags/local-evaluation). If you do that, give the key the **Feature flag** read scope too. In Node.js, if the client only sends email, set `enableLocalEvaluation: false`. +- **Retries:** The client sends an idempotency key on each send, and retries network errors, rate limits, and a `503` up to two times with the same key. Pass `idempotency_key` or `idempotencyKey` to cover retries outside the call too, such as from a job queue. +- **Errors:** In Python, a send that PostHog doesn't queue raises `EmailError`. In Node.js, `send` returns `{ data, error }` and doesn't throw. Both errors carry the `code` from the [errors table](#errors). ## Retry safely with an idempotency key @@ -224,6 +234,7 @@ Use `code` in your error handling. The text in `detail` can change. | `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. | | `422` | `recipient_suppressed` | The address is on your project's [suppression list](/docs/workflows/opt-outs#the-suppression-list) after a hard bounce or a spam complaint. | Don't retry. Remove the address from the suppression list only if you know that it can receive email again. | +| `422` | `recipient_unreachable` | The address isn't valid, or its domain has no mail servers, so the email would bounce. | Check the address for a typo. Don't retry the same address. | | `422` | `sending_paused` | Sending is paused for this transactional email because of its bounce or complaint rate. | Check the **Reputation** tab in Workflows. | | `422` | `sending_suspended` | Email sending is suspended for your project. | Check the **Reputation** tab in Workflows. | | `429` | `throttled` | You sent too many requests. | Wait for the number of seconds in the `Retry-After` header, then retry. | @@ -236,6 +247,87 @@ If you make a template transactional or save it without a verified sender, PostH Each project secret API key can send 600 requests per minute and 20,000 requests per hour. All the keys of one project share the same limits in total. A request over a limit gets a `429` with the code `throttled`. +## Check an email's status + +To find out what happened to an email, read its status with the `message_id` from the send: + +``` +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. + +A found email returns `200`: + +```json +{ + "id": "01963d8a-6f2c-7d4e-9b1a-3c5e8f2a7b10", + "status": "delivered", + "created_at": "2026-10-28T16:27:44.364000Z", + "status_at": "2026-10-28T16:27:49.625000Z", + "reason": null +} +``` + +| `status` | What it means | +| --- | --- | +| `queued` | The email waits to go out. | +| `sent` | PostHog handed the email to the email provider. | +| `failed` | The email wasn't sent. `reason` says why. | +| `delivered` | The recipient's mail server accepted the email. | +| `bounced` | The recipient's mail server refused the email. `reason` is `hard`, `soft`, or `unknown`. | +| `complained` | The recipient marked the email as spam. | + +A status only moves forward. For example, a `delivered` email never goes back to `sent`. New statuses can appear in the future, so handle a value you don't know. + +`reason` is `null` unless the email failed or bounced. A failed email has one of these reasons: + +- `provider_error` or `provider_rejected`: The email provider refused the email. +- `recipient_suppressed`, `recipient_unreachable`, or `missing_recipient`: PostHog skipped the email because of the recipient. +- `sending_paused` or `sending_suspended`: Sending is paused for the template or suspended for the project. Check the **Reputation** tab in Workflows. +- `canceled` or `workflow_unavailable`: The send stopped before the email went out. +- Another value, such as `timeout`: The send failed with this kind of error. New values can appear. + +A `failed` email can still change to `sent` if PostHog retries the send. + +### When a status isn't found + +The lookup returns `404` with the code `not_found` when: + +- The email was sent in the last few seconds. Its first status takes a moment to arrive, so retry after a short wait. +- The email is older than 30 days. PostHog keeps each status for 30 days after the send. +- No email of this project has the ID. + +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. + +### Examples + + + +```bash file=cURL +curl "https://us.posthog.com/api/projects/@current/messaging/emails//" \ + -H "Authorization: Bearer $POSTHOG_PROJECT_SECRET_API_KEY" +``` + +```python file=Python +status = posthog.emails.get(message_id) +if status.status == "bounced": + print(f"The email bounced: {status.reason}") +``` + +```js file=Node.js +const { data: status, error } = await posthog.emails.get(messageId) +if (status?.status === "bounced") { + console.log(`The email bounced: ${status.reason}`) +} +``` + + + +In Python, `get` returns an `EmailStatus`, with `created_at` and `status_at` as datetimes. It raises `EmailError` with the code `not_found` when the status isn't found. In Node.js, `get` returns `{ data, error }`. + ## How transactional emails behave - **No opt-outs or frequency caps.** Transactional emails ignore marketing [opt-outs](/docs/workflows/opt-outs) and frequency caps. Use them only for emails that a person needs, never for marketing. @@ -243,4 +335,4 @@ Each project secret API key can send 600 requests per minute and 20,000 requests - **Billing and quotas.** Transactional emails are billed and quota limited like other workflow emails. See [pricing](/pricing) and [billing limits](/docs/billing/limits-alerts). - **Sending allowance.** Transactional emails count toward your project's [sending allowance](/docs/workflows/sending-reputation). When your project reaches its hourly or daily cap, emails wait in the queue until the allowance frees up. If a transactional email gets too many bounces or complaints, PostHog can pause its sending, and sends return `sending_paused`. - **Engagement events.** If you [capture email engagement events](/docs/workflows/engagement-events), PostHog records them for transactional emails against the person in `identifiers.distinct_id`. -- **Where to see sends.** The template's **Sends** tab lists each send with its log messages. The **Metrics** tab shows their metrics. Each send also appears on the recipient's person. +- **Where to see sends.** [Read the status](#check-an-emails-status) of an email with its `message_id`. The template's **Sends** tab lists each send with its log messages. The **Metrics** tab shows their metrics. Each send also appears on the recipient's person. From 80fa5f8c14b8671029c73e4ec4d8cd7cd1fc51b0 Mon Sep 17 00:00:00 2001 From: Harley Alexander <43975092+mayteio@users.noreply.github.com> Date: Thu, 29 Oct 2026 11:27:52 +0000 Subject: [PATCH 03/26] docs(workflows): fix vale findings on the transactional email page Co-Authored-By: Claude Opus 5.5 Generated-By: PostHog Desktop Task-Id: e72a2c10-f145-4f7c-8754-a4fcc06a18be --- contents/docs/workflows/transactional-emails.mdx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/contents/docs/workflows/transactional-emails.mdx b/contents/docs/workflows/transactional-emails.mdx index d32164986846..bd9da0ab2b7b 100644 --- a/contents/docs/workflows/transactional-emails.mdx +++ b/contents/docs/workflows/transactional-emails.mdx @@ -187,9 +187,9 @@ The older route, `POST /api/projects/@current/messaging_templates/:template_id/s The [Python SDK](/docs/libraries/python) from version 7.81.0 and the [Node.js SDK](/docs/libraries/node) from version 5.61.0 send transactional emails with `emails.send`. -- **Key:** Set `secret_key` in Python or `secretKey` in Node.js to your project secret API key. The client still captures events with your project API key. +- **Key:** Set `secret_key` in Python or `secretKey` in Node.js to your project secret API key. The client still captures events with your project token. - **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 `app_host` in Python or `appHost` in Node.js to your PostHog app URL. The client refuses to send the secret key to a host that might be a proxy. -- **Local evaluation:** The client also uses the secret key to [evaluate feature flags locally](/docs/feature-flags/local-evaluation). If you do that, give the key the **Feature flag** read scope too. In Node.js, if the client only sends email, set `enableLocalEvaluation: false`. +- **Local evaluation:** The client also uses the secret key to [evaluate flags locally](/docs/feature-flags/local-evaluation). If you do that, give the key the **Feature flag** read scope too. In Node.js, if the client only sends email, set `enableLocalEvaluation: false`. - **Retries:** The client sends an idempotency key on each send, and retries network errors, rate limits, and a `503` up to two times with the same key. Pass `idempotency_key` or `idempotencyKey` to cover retries outside the call too, such as from a job queue. - **Errors:** In Python, a send that PostHog doesn't queue raises `EmailError`. In Node.js, `send` returns `{ data, error }` and doesn't throw. Both errors carry the `code` from the [errors table](#errors). @@ -326,7 +326,7 @@ if (status?.status === "bounced") { -In Python, `get` returns an `EmailStatus`, with `created_at` and `status_at` as datetimes. It raises `EmailError` with the code `not_found` when the status isn't found. In Node.js, `get` returns `{ data, error }`. +In Python, `get` returns an `EmailStatus`, with `created_at` and `status_at` as `datetime` objects. It raises `EmailError` with the code `not_found` when the status isn't found. In Node.js, `get` returns `{ data, error }`. ## How transactional emails behave From ded4a9a52364d3ecfaf8d0d54a173e1ad8f40ecb Mon Sep 17 00:00:00 2001 From: Harley Alexander <43975092+mayteio@users.noreply.github.com> Date: Thu, 29 Oct 2026 12:29:30 +0000 Subject: [PATCH 04/26] docs(workflows): address SDK review on the transactional email page - The Node.js example turns off local evaluation, and both examples keep the SDK's error. - The status examples handle the error first and explain retrying a not_found. - The SDK section adds install, client reuse, timeouts and retries, uncertain outcomes, and a no-network testing example. Co-Authored-By: Claude Opus 5.5 Generated-By: PostHog Desktop Task-Id: e72a2c10-f145-4f7c-8754-a4fcc06a18be --- .../docs/workflows/transactional-emails.mdx | 121 ++++++++++++++---- 1 file changed, 97 insertions(+), 24 deletions(-) diff --git a/contents/docs/workflows/transactional-emails.mdx b/contents/docs/workflows/transactional-emails.mdx index bd9da0ab2b7b..f54b09c7c9a1 100644 --- a/contents/docs/workflows/transactional-emails.mdx +++ b/contents/docs/workflows/transactional-emails.mdx @@ -132,7 +132,6 @@ curl -X POST "https://us.posthog.com/api/projects/@current/messaging/send/email/ import os from posthog import Posthog -from posthog.emails import EmailError posthog = Posthog( "", @@ -141,17 +140,15 @@ posthog = Posthog( ) def send_password_reset(user_id: str, email: str, first_name: str, reset_link: str, reset_request_id: str) -> str: - try: - result = posthog.emails.send( - to=email, - template_id="", - template_data={"first_name": first_name, "reset_link": reset_link}, - distinct_id=user_id, - # Identifies this one email, so a retry never sends it twice - idempotency_key=f"password-reset-{reset_request_id}", - ) - except EmailError as error: - raise RuntimeError(f"PostHog refused the email: {error.code}") from error + # Raises posthog.emails.EmailError when PostHog doesn't queue the email. Branch on error.code. + result = posthog.emails.send( + to=email, + template_id="", + template_data={"first_name": first_name, "reset_link": reset_link}, + distinct_id=user_id, + # Identifies this one email, so a retry never sends it twice + idempotency_key=f"password-reset-{reset_request_id}", + ) return result.id ``` @@ -161,6 +158,8 @@ import { PostHog } from "posthog-node" const posthog = new PostHog("", { host: "https://us.i.posthog.com", 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, }) export async function sendPasswordReset({ userId, email, firstName, resetLink, resetRequestId }) { @@ -173,7 +172,8 @@ export async function sendPasswordReset({ userId, email, firstName, resetLink, r idempotencyKey: `password-reset-${resetRequestId}`, }) if (error) { - throw new Error(`PostHog refused the email: ${error.code}`) + // An EmailError. Branch on error.code + throw error } return data.id } @@ -187,11 +187,69 @@ The older route, `POST /api/projects/@current/messaging_templates/:template_id/s The [Python SDK](/docs/libraries/python) from version 7.81.0 and the [Node.js SDK](/docs/libraries/node) from version 5.61.0 send transactional emails with `emails.send`. -- **Key:** Set `secret_key` in Python or `secretKey` in Node.js to your project secret API key. The client still captures events with your project token. +- **Install:** `pip install posthog` for Python, or `npm install posthog-node` for Node.js. For async Python code, install `posthog[async]` and use `AsyncPosthog`. +- **Client:** Reuse the client your application already creates. Set `secret_key` in Python or `secretKey` in Node.js to your project secret API key. The client still captures events with your project token. Shut the client down when your application exits, not after each send. - **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 `app_host` in Python or `appHost` in Node.js to your PostHog app URL. The client refuses to send the secret key to a host that might be a proxy. -- **Local evaluation:** The client also uses the secret key to [evaluate flags locally](/docs/feature-flags/local-evaluation). If you do that, give the key the **Feature flag** read scope too. In Node.js, if the client only sends email, set `enableLocalEvaluation: false`. -- **Retries:** The client sends an idempotency key on each send, and retries network errors, rate limits, and a `503` up to two times with the same key. Pass `idempotency_key` or `idempotencyKey` to cover retries outside the call too, such as from a job queue. -- **Errors:** In Python, a send that PostHog doesn't queue raises `EmailError`. In Node.js, `send` returns `{ data, error }` and doesn't throw. Both errors carry the `code` from the [errors table](#errors). +- **Local evaluation:** The client also uses the secret key to [evaluate flags locally](/docs/feature-flags/local-evaluation). If your application does that, give the key the **Feature flag** read scope too. In Node.js, a client that only sends email needs `enableLocalEvaluation: false`, because the client starts polling flag definitions when it starts. In Python, the client loads flag definitions only when it evaluates a flag. +- **Timeouts and retries:** Each request times out after 15 seconds. The client retries network errors, rate limits, an in-flight idempotency key, and a `503` up to two times, with the same idempotency key, waiting about 1 and then 2 seconds, or longer if the server's `Retry-After` asks for it. A `Retry-After` over 30 seconds returns the error at once. To change this, set `email_request_timeout_seconds` and `email_request_max_retries` in Python, or `emailRequestTimeoutMs` and `emailRequestMaxRetries` in Node.js. When a job queue already retries the send, set the retries to `0` and pass an `idempotency_key` or `idempotencyKey` that identifies the email. +- **Errors:** In Python, a send that PostHog doesn't queue raises `EmailError`. In Node.js, `send` returns `{ data, error }` and doesn't throw. Both errors carry the `code` from the [errors table](#errors). After a `network_error` or 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. + +#### Test without sending email + +Pass your own HTTP transport to check what your code sends, without a network call. This checks your integration, not delivery. To check delivery, send a real email to your own address. + + + +```python file=Python +import httpx +from posthog.emails import AsyncEmails + +requests = [] + +def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(202, json={"message_id": "msg_1", "replayed": False}) + +async def test_send_password_reset(): + emails = AsyncEmails( + "phs_test", + host="https://us.posthog.com", + http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)), + ) + + result = await emails.send(to="jane@example.com", template_id="", idempotency_key="password-reset-1") + + assert result.id == "msg_1" + assert requests[0].headers["Idempotency-Key"] == "password-reset-1" +``` + +```js file=Node.js +import assert from "node:assert/strict" +import { PostHog } from "posthog-node" + +const requests = [] +const posthog = new PostHog("phc_test", { + secretKey: "phs_test", + enableLocalEvaluation: false, + fetch: async (url, options) => { + requests.push({ url, options }) + return new Response(JSON.stringify({ message_id: "msg_1", replayed: false }), { status: 202 }) + }, +}) + +const { data } = await posthog.emails.send({ + to: "jane@example.com", + templateId: "", + idempotencyKey: "password-reset-1", +}) + +assert.equal(data.id, "msg_1") +assert.equal(requests[0].options.headers["Idempotency-Key"], "password-reset-1") +``` + + + +For synchronous Python code, pass a `requests.Session` with a test adapter to `Emails(session=...)`. Any library that mocks `requests` or `httpx` works too. ## Retry safely with an idempotency key @@ -312,21 +370,36 @@ curl "https://us.posthog.com/api/projects/@current/messaging/emails/ ``` ```python file=Python -status = posthog.emails.get(message_id) -if status.status == "bounced": - print(f"The email bounced: {status.reason}") +from posthog.emails import EmailError + +def email_bounced(message_id: str) -> bool | None: + try: + status = posthog.emails.get(message_id) + except EmailError as error: + if error.code == "not_found": + # The first status can take a few seconds to arrive. Try again later. + return None + raise + return status.status == "bounced" ``` ```js file=Node.js -const { data: status, error } = await posthog.emails.get(messageId) -if (status?.status === "bounced") { - console.log(`The email bounced: ${status.reason}`) +export async function emailBounced(messageId) { + const { data: status, error } = await posthog.emails.get(messageId) + if (error) { + if (error.code === "not_found") { + // The first status can take a few seconds to arrive. Try again later. + return null + } + throw error + } + return status.status === "bounced" } ``` -In Python, `get` returns an `EmailStatus`, with `created_at` and `status_at` as `datetime` objects. It raises `EmailError` with the code `not_found` when the status isn't found. In Node.js, `get` returns `{ data, error }`. +In Python, `get` returns an `EmailStatus`, with `created_at` and `status_at` as `datetime` objects. It raises `EmailError` with the code `not_found` when the status isn't found. In Node.js, `get` returns `{ data, error }`. Neither SDK retries a `not_found`, so retry it yourself a few times, with a wait in between, before you treat the email as missing. A status that isn't there yet doesn't mean the email failed. ## How transactional emails behave From 3fed7e4e0485cc047991a60cc3f3ad252dbbccf5 Mon Sep 17 00:00:00 2001 From: Harley Alexander <43975092+mayteio@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:01:28 +0000 Subject: [PATCH 05/26] docs(workflows): split transactional emails into overview and platform pages The overview keeps the template, key, idempotency, errors, statuses and behavior. Node.js, Python and API pages hold the setup, send, status, retries and testing for each platform, with a sidebar group like push notifications. Co-Authored-By: Claude Opus 5.5 Generated-By: PostHog Desktop Task-Id: e72a2c10-f145-4f7c-8754-a4fcc06a18be --- .../workflows/transactional-emails/api.mdx | 112 ++++++++ .../index.mdx} | 255 +----------------- .../workflows/transactional-emails/node.mdx | 105 ++++++++ .../workflows/transactional-emails/python.mdx | 140 ++++++++++ src/navs/index.js | 18 ++ 5 files changed, 388 insertions(+), 242 deletions(-) create mode 100644 contents/docs/workflows/transactional-emails/api.mdx rename contents/docs/workflows/{transactional-emails.mdx => transactional-emails/index.mdx} (51%) create mode 100644 contents/docs/workflows/transactional-emails/node.mdx create mode 100644 contents/docs/workflows/transactional-emails/python.mdx diff --git a/contents/docs/workflows/transactional-emails/api.mdx b/contents/docs/workflows/transactional-emails/api.mdx new file mode 100644 index 000000000000..2e455c890288 --- /dev/null +++ b/contents/docs/workflows/transactional-emails/api.mdx @@ -0,0 +1,112 @@ +--- +title: Send transactional emails with the API +sidebar: Docs +showTitle: true +--- + +Use the API to send transactional emails from any language. For Node.js and Python, the [Node.js SDK](/docs/workflows/transactional-emails/node) and the [Python SDK](/docs/workflows/transactional-emails/python) handle retries and idempotency keys for you. + +Before you start, [make a template transactional](/docs/workflows/transactional-emails#make-a-template-transactional) and [create a project secret API key](/docs/workflows/transactional-emails#create-an-api-key). + +## Send an email + +``` +POST https://us.posthog.com/api/projects/@current/messaging/send/email/ +``` + +- **Host:** Use `https://us.posthog.com` or `https://eu.posthog.com`, the host where you log in to PostHog. Don't use an ingestion host like `us.i.posthog.com`. If you self-host PostHog, use the URL of your instance. +- **Project:** `@current` is the project that the key belongs to. You can use your project ID instead. + +### Request + +Send these headers: + +| Header | Value | +| --- | --- | +| `Authorization` | `Bearer` followed by your project secret API key | +| `Content-Type` | `application/json` | +| `Idempotency-Key` | Optional. A unique key for this email. See [retry safely](/docs/workflows/transactional-emails#retry-safely-with-an-idempotency-key). | + +Send a JSON body with these fields: + +| Field | Required | Description | +| --- | --- | --- | +| `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. | +| `identifiers.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. | + +In the template, use the same names that you send in `message_data`: + +```liquid +Hi {{ variables.first_name }}, use this link to reset your password: {{ variables.reset_link }} +``` + +### Response + +A successful request returns `202 Accepted`: + +```json +{ + "message_id": "01963d8a-6f2c-7d4e-9b1a-3c5e8f2a7b10", + "replayed": false +} +``` + +A `202` means that PostHog queued the email. It doesn't mean that the email was delivered. To see what happened to the email, [check its status](#check-an-emails-status) with its `message_id`, or open the template's **Sends** tab. + +`replayed` is `true` when an earlier request with the same `Idempotency-Key` already queued this email. In that case, PostHog sent no new email, and `message_id` is the ID of the first send. + +### Example + +```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": { "id": "" }, + "to": "jane@example.com", + "identifiers": { "distinct_id": "user_123" }, + "message_data": { "first_name": "Jane", "reset_link": "https://example.com/reset?token=abc123" } + }' +``` + +The older route, `POST /api/projects/@current/messaging_templates/:template_id/send/`, takes the same body without `template` and still works. + +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. + +## Check an email's status + +To find out what happened to an email, read its status with the `message_id` from the send: + +``` +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. + +A found email returns `200`: + +```json +{ + "id": "01963d8a-6f2c-7d4e-9b1a-3c5e8f2a7b10", + "status": "delivered", + "created_at": "2026-10-28T16:27:44.364000Z", + "status_at": "2026-10-28T16:27:49.625000Z", + "reason": null +} +``` + +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. + +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. + +### Example + +```bash +curl "https://us.posthog.com/api/projects/@current/messaging/emails//" \ + -H "Authorization: Bearer $POSTHOG_PROJECT_SECRET_API_KEY" +``` diff --git a/contents/docs/workflows/transactional-emails.mdx b/contents/docs/workflows/transactional-emails/index.mdx similarity index 51% rename from contents/docs/workflows/transactional-emails.mdx rename to contents/docs/workflows/transactional-emails/index.mdx index f54b09c7c9a1..cf601d5956a3 100644 --- a/contents/docs/workflows/transactional-emails.mdx +++ b/contents/docs/workflows/transactional-emails/index.mdx @@ -63,193 +63,27 @@ To give a service a key that can read statuses but not send, set **Workflow** to ## Send an email -``` -POST https://us.posthog.com/api/projects/@current/messaging/send/email/ -``` - -- **Host:** Use `https://us.posthog.com` or `https://eu.posthog.com`, the host where you log in to PostHog. Don't use an ingestion host like `us.i.posthog.com`. If you self-host PostHog, use the URL of your instance. -- **Project:** `@current` is the project that the key belongs to. You can use your project ID instead. -- **Template ID:** Send it in the body as `template.id`. The template's **API** tab shows it. +Your backend sends each email with one call. A send needs: -### Request +- **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. +- **An idempotency key.** It makes a retry safe. See [retry safely](#retry-safely-with-an-idempotency-key). -Send these headers: - -| Header | Value | -| --- | --- | -| `Authorization` | `Bearer` followed by your project secret API key | -| `Content-Type` | `application/json` | -| `Idempotency-Key` | Optional. A unique key for this email. See [retry safely](#retry-safely-with-an-idempotency-key). | - -Send a JSON body with these fields: - -| Field | Required | Description | -| --- | --- | --- | -| `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. | -| `identifiers.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. | - -In the template, use the same names that you send in `message_data`: +In the template, use the same names that you send in the template data: ```liquid Hi {{ variables.first_name }}, use this link to reset your password: {{ variables.reset_link }} ``` -### Response - -A successful request returns `202 Accepted`: - -```json -{ - "message_id": "01963d8a-6f2c-7d4e-9b1a-3c5e8f2a7b10", - "replayed": false -} -``` - -A `202` means that PostHog queued the email. It doesn't mean that the email was delivered. To see what happened to the email, [check its status](#check-an-emails-status) with its `message_id`, or open the template's **Sends** tab. - -`replayed` is `true` when an earlier request with the same `Idempotency-Key` already queued this email. In that case, PostHog sent no new email, and `message_id` is the ID of the first send. - -### Examples - - - -```bash file=cURL -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": { "id": "" }, - "to": "jane@example.com", - "identifiers": { "distinct_id": "user_123" }, - "message_data": { "first_name": "Jane", "reset_link": "https://example.com/reset?token=abc123" } - }' -``` - -```python file=Python -import os - -from posthog import Posthog - -posthog = Posthog( - "", - host="https://us.i.posthog.com", - secret_key=os.environ["POSTHOG_PROJECT_SECRET_API_KEY"], -) - -def send_password_reset(user_id: str, email: str, first_name: str, reset_link: str, reset_request_id: str) -> str: - # Raises posthog.emails.EmailError when PostHog doesn't queue the email. Branch on error.code. - result = posthog.emails.send( - to=email, - template_id="", - template_data={"first_name": first_name, "reset_link": reset_link}, - distinct_id=user_id, - # Identifies this one email, so a retry never sends it twice - idempotency_key=f"password-reset-{reset_request_id}", - ) - return result.id -``` - -```js file=Node.js -import { PostHog } from "posthog-node" - -const posthog = new PostHog("", { - host: "https://us.i.posthog.com", - 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, -}) - -export async function sendPasswordReset({ userId, email, firstName, resetLink, resetRequestId }) { - const { data, error } = await posthog.emails.send({ - to: email, - templateId: "", - templateData: { first_name: firstName, reset_link: resetLink }, - distinctId: userId, - // Identifies this one email, so a retry never sends it twice - idempotencyKey: `password-reset-${resetRequestId}`, - }) - if (error) { - // An EmailError. Branch on error.code - throw error - } - return data.id -} -``` - - - -The older route, `POST /api/projects/@current/messaging_templates/:template_id/send/`, takes the same body without `template` and still works. - -### Send with an SDK - -The [Python SDK](/docs/libraries/python) from version 7.81.0 and the [Node.js SDK](/docs/libraries/node) from version 5.61.0 send transactional emails with `emails.send`. - -- **Install:** `pip install posthog` for Python, or `npm install posthog-node` for Node.js. For async Python code, install `posthog[async]` and use `AsyncPosthog`. -- **Client:** Reuse the client your application already creates. Set `secret_key` in Python or `secretKey` in Node.js to your project secret API key. The client still captures events with your project token. Shut the client down when your application exits, not after each send. -- **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 `app_host` in Python or `appHost` in Node.js to your PostHog app URL. The client refuses to send the secret key to a host that might be a proxy. -- **Local evaluation:** The client also uses the secret key to [evaluate flags locally](/docs/feature-flags/local-evaluation). If your application does that, give the key the **Feature flag** read scope too. In Node.js, a client that only sends email needs `enableLocalEvaluation: false`, because the client starts polling flag definitions when it starts. In Python, the client loads flag definitions only when it evaluates a flag. -- **Timeouts and retries:** Each request times out after 15 seconds. The client retries network errors, rate limits, an in-flight idempotency key, and a `503` up to two times, with the same idempotency key, waiting about 1 and then 2 seconds, or longer if the server's `Retry-After` asks for it. A `Retry-After` over 30 seconds returns the error at once. To change this, set `email_request_timeout_seconds` and `email_request_max_retries` in Python, or `emailRequestTimeoutMs` and `emailRequestMaxRetries` in Node.js. When a job queue already retries the send, set the retries to `0` and pass an `idempotency_key` or `idempotencyKey` that identifies the email. -- **Errors:** In Python, a send that PostHog doesn't queue raises `EmailError`. In Node.js, `send` returns `{ data, error }` and doesn't throw. Both errors carry the `code` from the [errors table](#errors). After a `network_error` or 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. - -#### Test without sending email - -Pass your own HTTP transport to check what your code sends, without a network call. This checks your integration, not delivery. To check delivery, send a real email to your own address. - - +A send returns a message ID. It means that PostHog queued the email, not that the email was delivered. To see what happened to the email, [check its status](#check-an-emails-status), or open the template's **Sends** tab. -```python file=Python -import httpx -from posthog.emails import AsyncEmails +Set up sending for your platform: -requests = [] - -def handler(request: httpx.Request) -> httpx.Response: - requests.append(request) - return httpx.Response(202, json={"message_id": "msg_1", "replayed": False}) - -async def test_send_password_reset(): - emails = AsyncEmails( - "phs_test", - host="https://us.posthog.com", - http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)), - ) - - result = await emails.send(to="jane@example.com", template_id="", idempotency_key="password-reset-1") - - assert result.id == "msg_1" - assert requests[0].headers["Idempotency-Key"] == "password-reset-1" -``` - -```js file=Node.js -import assert from "node:assert/strict" -import { PostHog } from "posthog-node" - -const requests = [] -const posthog = new PostHog("phc_test", { - secretKey: "phs_test", - enableLocalEvaluation: false, - fetch: async (url, options) => { - requests.push({ url, options }) - return new Response(JSON.stringify({ message_id: "msg_1", replayed: false }), { status: 202 }) - }, -}) - -const { data } = await posthog.emails.send({ - to: "jane@example.com", - templateId: "", - idempotencyKey: "password-reset-1", -}) - -assert.equal(data.id, "msg_1") -assert.equal(requests[0].options.headers["Idempotency-Key"], "password-reset-1") -``` - - - -For synchronous Python code, pass a `requests.Session` with a test adapter to `Emails(session=...)`. Any library that mocks `requests` or `httpx` works too. +- [Node.js](/docs/workflows/transactional-emails/node) +- [Python](/docs/workflows/transactional-emails/python) +- [Other languages, with the API](/docs/workflows/transactional-emails/api) ## Retry safely with an idempotency key @@ -307,25 +141,7 @@ Each project secret API key can send 600 requests per minute and 20,000 requests ## Check an email's status -To find out what happened to an email, read its status with the `message_id` from the send: - -``` -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. - -A found email returns `200`: - -```json -{ - "id": "01963d8a-6f2c-7d4e-9b1a-3c5e8f2a7b10", - "status": "delivered", - "created_at": "2026-10-28T16:27:44.364000Z", - "status_at": "2026-10-28T16:27:49.625000Z", - "reason": null -} -``` +Each send returns a message ID. Read the email's status with it, for 30 days after the send. Your platform's page shows how. | `status` | What it means | | --- | --- | @@ -356,51 +172,6 @@ The lookup returns `404` with the code `not_found` when: - The email is older than 30 days. PostHog keeps each status for 30 days after the send. - No email of this project has the ID. -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. - -### Examples - - - -```bash file=cURL -curl "https://us.posthog.com/api/projects/@current/messaging/emails//" \ - -H "Authorization: Bearer $POSTHOG_PROJECT_SECRET_API_KEY" -``` - -```python file=Python -from posthog.emails import EmailError - -def email_bounced(message_id: str) -> bool | None: - try: - status = posthog.emails.get(message_id) - except EmailError as error: - if error.code == "not_found": - # The first status can take a few seconds to arrive. Try again later. - return None - raise - return status.status == "bounced" -``` - -```js file=Node.js -export async function emailBounced(messageId) { - const { data: status, error } = await posthog.emails.get(messageId) - if (error) { - if (error.code === "not_found") { - // The first status can take a few seconds to arrive. Try again later. - return null - } - throw error - } - return status.status === "bounced" -} -``` - - - -In Python, `get` returns an `EmailStatus`, with `created_at` and `status_at` as `datetime` objects. It raises `EmailError` with the code `not_found` when the status isn't found. In Node.js, `get` returns `{ data, error }`. Neither SDK retries a `not_found`, so retry it yourself a few times, with a wait in between, before you treat the email as missing. A status that isn't there yet doesn't mean the email failed. - ## How transactional emails behave - **No opt-outs or frequency caps.** Transactional emails ignore marketing [opt-outs](/docs/workflows/opt-outs) and frequency caps. Use them only for emails that a person needs, never for marketing. diff --git a/contents/docs/workflows/transactional-emails/node.mdx b/contents/docs/workflows/transactional-emails/node.mdx new file mode 100644 index 000000000000..e0efe0d9421f --- /dev/null +++ b/contents/docs/workflows/transactional-emails/node.mdx @@ -0,0 +1,105 @@ +--- +title: Send transactional emails with Node.js +sidebar: Docs +showTitle: true +--- + +The [Node.js SDK](/docs/libraries/node) sends transactional emails from version 5.61.0. Before you start, [make a template transactional](/docs/workflows/transactional-emails#make-a-template-transactional) and [create a project secret API key](/docs/workflows/transactional-emails#create-an-api-key). + +## Install + +```bash +npm install posthog-node +``` + +## Set up the client + +Reuse the PostHog client your application already creates, and set `secretKey` to your project secret API key. The client still captures events with your project token. Shut the client down when your application exits, not after each send. + +```js +import { PostHog } from "posthog-node" + +const posthog = new PostHog("", { + host: "https://us.i.posthog.com", + 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. +- **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 + +```js +export async function sendPasswordReset({ userId, email, firstName, resetLink, resetRequestId }) { + const { data, error } = await posthog.emails.send({ + to: email, + templateId: "", + templateData: { first_name: firstName, reset_link: resetLink }, + distinctId: userId, + // Identifies this one email, so a retry never sends it twice + idempotencyKey: `password-reset-${resetRequestId}`, + }) + if (error) { + // An EmailError. Branch on error.code + throw error + } + return data.id +} +``` + +`send` returns `{ data, error }` and doesn't throw. `data.id` is the message ID. `error` is an `EmailError` with the `code` from the [errors table](/docs/workflows/transactional-emails#errors). After a `network_error` or 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. + +### Timeouts and retries + +Each request times out after 15 seconds. The client retries network errors, rate limits, an in-flight idempotency key, and a `503` up to two times, with the same idempotency key. It waits about 1 and then 2 seconds, or longer if the server's `Retry-After` asks for it. A `Retry-After` over 30 seconds returns the error at once. + +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. + +## Check an email's status + +```js +export async function emailBounced(messageId) { + const { data: status, error } = await posthog.emails.get(messageId) + if (error) { + if (error.code === "not_found") { + // The first status can take a few seconds to arrive. Try again later. + return null + } + throw error + } + return status.status === "bounced" +} +``` + +`get` returns `{ data, error }`. `data` has `id`, `status`, `createdAt`, `statusAt`, and `reason`. See [statuses and reasons](/docs/workflows/transactional-emails#check-an-emails-status). The client doesn't retry a `not_found`, so retry it yourself a few times, with a wait in between, before you treat the email as missing. A status that isn't there yet doesn't mean the email failed. + +## Test without sending email + +Pass your own `fetch` to check what your code sends, without a network call. This checks your integration, not delivery. To check delivery, send a real email to your own address. + +```js +import assert from "node:assert/strict" +import { PostHog } from "posthog-node" + +const requests = [] +const posthog = new PostHog("phc_test", { + secretKey: "phs_test", + enableLocalEvaluation: false, + fetch: async (url, options) => { + requests.push({ url, options }) + return new Response(JSON.stringify({ message_id: "msg_1", replayed: false }), { status: 202 }) + }, +}) + +const { data } = await posthog.emails.send({ + to: "jane@example.com", + templateId: "", + idempotencyKey: "password-reset-1", +}) + +assert.equal(data.id, "msg_1") +assert.equal(requests[0].options.headers["Idempotency-Key"], "password-reset-1") +``` diff --git a/contents/docs/workflows/transactional-emails/python.mdx b/contents/docs/workflows/transactional-emails/python.mdx new file mode 100644 index 000000000000..8a302011f17e --- /dev/null +++ b/contents/docs/workflows/transactional-emails/python.mdx @@ -0,0 +1,140 @@ +--- +title: Send transactional emails with Python +sidebar: Docs +showTitle: true +--- + +The [Python SDK](/docs/libraries/python) sends transactional emails from version 7.81.0. Before you start, [make a template transactional](/docs/workflows/transactional-emails#make-a-template-transactional) and [create a project secret API key](/docs/workflows/transactional-emails#create-an-api-key). + +## Install + +```bash +pip install posthog +``` + +For async code, install `posthog[async]` instead. + +## Set up the client + +Reuse the PostHog client your application already creates, and set `secret_key` to your project secret API key. The client still captures events with your project token. Shut the client down when your application exits, not after each send. + + + +```python file=Sync +import os + +from posthog import Posthog + +posthog = Posthog( + "", + host="https://us.i.posthog.com", + secret_key=os.environ["POSTHOG_PROJECT_SECRET_API_KEY"], +) +``` + +```python file=Async +import os + +from posthog import AsyncPosthog + +posthog = AsyncPosthog( + "", + host="https://us.i.posthog.com", + secret_key=os.environ["POSTHOG_PROJECT_SECRET_API_KEY"], +) +``` + + + +- **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 `app_host` to your PostHog app URL. The client refuses to send the secret key to a host that might be a proxy. +- **Local evaluation:** The client loads flag definitions only when it evaluates a flag. If your application [evaluates flags locally](/docs/feature-flags/local-evaluation) with the same client, give the key the **Feature flag** read scope too. +- **Email only:** In a process that only sends email, you can use `Emails` or `AsyncEmails` from `posthog.emails` instead of a full client. + +## Send an email + + + +```python file=Sync +def send_password_reset(user_id: str, email: str, first_name: str, reset_link: str, reset_request_id: str) -> str: + # Raises posthog.emails.EmailError when PostHog doesn't queue the email. Branch on error.code. + result = posthog.emails.send( + to=email, + template_id="", + template_data={"first_name": first_name, "reset_link": reset_link}, + distinct_id=user_id, + # Identifies this one email, so a retry never sends it twice + idempotency_key=f"password-reset-{reset_request_id}", + ) + return result.id +``` + +```python file=Async +async def send_password_reset(user_id: str, email: str, first_name: str, reset_link: str, reset_request_id: str) -> str: + # Raises posthog.emails.EmailError when PostHog doesn't queue the email. Branch on error.code. + result = await posthog.emails.send( + to=email, + template_id="", + template_data={"first_name": first_name, "reset_link": reset_link}, + distinct_id=user_id, + # Identifies this one email, so a retry never sends it twice + idempotency_key=f"password-reset-{reset_request_id}", + ) + return result.id +``` + + + +`send` returns a `SendEmailResult`, and `result.id` is the message ID. A send that PostHog doesn't queue raises `EmailError`, with the `code` from the [errors table](/docs/workflows/transactional-emails#errors). A wrong argument raises `TypeError` or `ValueError` before any request. After a `network_error` or 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. + +### Timeouts and retries + +Each request times out after 15 seconds. The client retries network errors, rate limits, an in-flight idempotency key, and a `503` up to two times, with the same idempotency key. It waits about 1 and then 2 seconds, or longer if the server's `Retry-After` asks for it. A `Retry-After` over 30 seconds returns the error at once. + +To change this, set `email_request_timeout_seconds` and `email_request_max_retries` on the client. When a job queue already retries the send, set `email_request_max_retries=0` and pass an `idempotency_key` that identifies the email. + +## Check an email's status + +```python +from posthog.emails import EmailError + +def email_bounced(message_id: str) -> bool | None: + try: + status = posthog.emails.get(message_id) + except EmailError as error: + if error.code == "not_found": + # The first status can take a few seconds to arrive. Try again later. + return None + raise + return status.status == "bounced" +``` + +`get` returns an `EmailStatus` with `id`, `status`, `created_at`, `status_at`, and `reason`. The two times are `datetime` objects. See [statuses and reasons](/docs/workflows/transactional-emails#check-an-emails-status). The client doesn't retry a `not_found`, so retry it yourself a few times, with a wait in between, before you treat the email as missing. A status that isn't there yet doesn't mean the email failed. + +## Test without sending email + +Pass your own HTTP transport to check what your code sends, without a network call. This checks your integration, not delivery. To check delivery, send a real email to your own address. + +```python +import httpx +from posthog.emails import AsyncEmails + +requests = [] + +def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(202, json={"message_id": "msg_1", "replayed": False}) + +async def test_send_password_reset(): + emails = AsyncEmails( + "phs_test", + host="https://us.posthog.com", + http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)), + ) + + result = await emails.send(to="jane@example.com", template_id="", idempotency_key="password-reset-1") + + assert result.id == "msg_1" + assert requests[0].headers["Idempotency-Key"] == "password-reset-1" +``` + +For synchronous code, pass a `requests.Session` with a test adapter to `Emails(session=...)`. Any library that mocks `requests` or `httpx` works too. diff --git a/src/navs/index.js b/src/navs/index.js index 68b02cd2ee3e..b11db113c6e1 100644 --- a/src/navs/index.js +++ b/src/navs/index.js @@ -8510,6 +8510,24 @@ export const docsMenu = { url: '/docs/workflows/transactional-emails', icon: 'IconLetter', color: 'blue', + children: [ + { + name: 'Overview', + url: '/docs/workflows/transactional-emails', + }, + { + name: 'Node.js', + url: '/docs/workflows/transactional-emails/node', + }, + { + name: 'Python', + url: '/docs/workflows/transactional-emails/python', + }, + { + name: 'API', + url: '/docs/workflows/transactional-emails/api', + }, + ], }, { name: 'Channels', From b087284d71b37256a057429b051d9dbe1d98bf5e Mon Sep 17 00:00:00 2001 From: Harley Alexander <43975092+mayteio@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:47:31 +0000 Subject: [PATCH 06/26] docs(workflows): add a quick start and the Transactional section to the overview - A quick start links to the Node.js, Python and API pages. - Creating an email follows the Transactional section's wizard, and the hidden workflow explanation is gone. - Agents can set it up with the PostHog MCP server. - Protect sensitive data turns off click tracking and keeps deliveries, opens and statuses. Co-Authored-By: Claude Opus 5.5 Generated-By: PostHog Desktop Task-Id: e72a2c10-f145-4f7c-8754-a4fcc06a18be --- .../workflows/transactional-emails/api.mdx | 2 +- .../workflows/transactional-emails/index.mdx | 48 +++++++++++-------- .../workflows/transactional-emails/node.mdx | 2 +- .../workflows/transactional-emails/python.mdx | 2 +- 4 files changed, 31 insertions(+), 23 deletions(-) diff --git a/contents/docs/workflows/transactional-emails/api.mdx b/contents/docs/workflows/transactional-emails/api.mdx index 2e455c890288..bece15c735b4 100644 --- a/contents/docs/workflows/transactional-emails/api.mdx +++ b/contents/docs/workflows/transactional-emails/api.mdx @@ -6,7 +6,7 @@ showTitle: true Use the API to send transactional emails from any language. For Node.js and Python, the [Node.js SDK](/docs/workflows/transactional-emails/node) and the [Python SDK](/docs/workflows/transactional-emails/python) handle retries and idempotency keys for you. -Before you start, [make a template transactional](/docs/workflows/transactional-emails#make-a-template-transactional) and [create a project secret API key](/docs/workflows/transactional-emails#create-an-api-key). +Before you start, [create a transactional email](/docs/workflows/transactional-emails#create-a-transactional-email) and [create a project secret API key](/docs/workflows/transactional-emails#create-an-api-key). ## Send an email diff --git a/contents/docs/workflows/transactional-emails/index.mdx b/contents/docs/workflows/transactional-emails/index.mdx index cf601d5956a3..3e052b3aee11 100644 --- a/contents/docs/workflows/transactional-emails/index.mdx +++ b/contents/docs/workflows/transactional-emails/index.mdx @@ -10,43 +10,51 @@ Transactional emails are a new feature. Things might change or break while we wo -Transactional emails are one-off emails that a person triggers in your product, like password resets, sign-in codes, and receipts. You design the email as a [message template](/docs/workflows/library) in Workflows. Your backend then sends it to one recipient with one API call. +Transactional emails are one-off emails that a person triggers in your product, like password resets, sign-in codes, and receipts. You write the email once in PostHog. Your backend then sends it to one recipient with one API call. -Each transactional template has its own workflow behind it. PostHog doesn't show this workflow on the **Workflows** tab. It shows the workflow's data on the template instead: +**Quick start:** [Node.js](/docs/workflows/transactional-emails/node) · [Python](/docs/workflows/transactional-emails/python) · [API](/docs/workflows/transactional-emails/api) -- **Metrics** shows the metrics for the template's sends. +## Create a transactional email + +1. Open [**Transactional**](https://app.posthog.com/transactional) in PostHog and click **New transactional email**. +2. **Name:** Name the email, and turn on [**Protect sensitive data**](#protect-sensitive-data) if it carries sign-in codes or reset links. +3. **Sender:** Choose the verified sender the email comes from. To set up a sender, see [configure a channel](/docs/workflows/configure-channels). +4. **Content:** Write the subject and the body. Add the variables your code sends, such as `reset_link`, and use them as `{{ variables.reset_link }}`. +5. **Send from your code:** Copy the example with the email's template ID. + +Each transactional email has its own page in **Transactional**, with these tabs: + +- **Design** edits the email and its sender. +- **Metrics** shows the metrics for its sends. - **Sends** lists each send, with its log messages. -- **Versions** keeps each version of the email that you publish. +- **Versions** keeps each version that you publish. +- **API** shows the template ID and an example send. -## Make a template transactional + -1. In Workflows, open the [**Templates** tab](https://app.posthog.com/workflows/library) and choose **Email templates**. -2. Open the template you want to send, or create one. -3. Open the **API** tab and click **Make transactional**. +Transactional emails ignore marketing opt-outs, so use them only for emails that a person needs, never for marketing. A transactional email can't be changed back to a regular template. -The template needs these before you can make it transactional: + -- **A subject and a body.** Save any changes first. -- **A verified email sender.** PostHog uses your project's default sender. You can change the sender later. To set up a sender, see [configure a channel](/docs/workflows/configure-channels). -- **No marketing category.** Transactional emails ignore opt-outs, so PostHog doesn't send a template that is in a marketing [message category](/docs/workflows/opt-outs#message-categories). +### Use an existing template - +To send an email template you already have, open it in the **Templates** tab, open its **API** tab, and click **Make transactional**. The template needs a subject and a body, a verified sender, and no marketing [message category](/docs/workflows/opt-outs#message-categories). If you still use the template for other emails, click **Duplicate** in its menu first, and make the copy transactional. -A template stays transactional after you make it transactional. If you still use the template for other emails, click **Duplicate** in the template's menu first, and make the copy transactional. +### Set it up with an agent - +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 over 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 `hog_flow:write` scope. -### Edit a transactional template +### Edit a transactional email - **Sender:** The **Design** tab shows who the email comes from. Click **Edit sender** to choose a different verified sender, or to set a display name, a from address, or a reply-to address. The from address must be on the sender's verified domain. To use more than one reply-to address, separate them with commas. - **Drafts:** Your edits save as a draft. Sends use the live version of the email until you click **Publish**. Each publish adds a version on the **Versions** tab. To remove your edits, use **Discard draft**. -- **Checks:** PostHog doesn't publish a change that would stop the template from sending, such as an empty subject or body, or a marketing category. +- **Checks:** PostHog doesn't publish a change that would stop the email from sending, such as an empty subject or body, or a marketing category. ### Protect sensitive data -For emails with sign-in codes, reset links, or other secrets, turn on **Protect sensitive data** on the **API** tab. PostHog then keeps no copy of the body or the filled-in subject of each email, and clicked links keep only the site address. You can turn this setting on or off at any time. +For emails with sign-in codes, reset links, or other secrets, turn on **Protect sensitive data**, in the first step of a new email or on its **API** tab. PostHog then keeps no copy of what each email contained, and doesn't track link clicks, so a code or a token in a link never goes through a tracking redirect. Deliveries, opens, bounces, and the email's status are still tracked. You can turn this setting on or off at any time. ## Create an API key @@ -116,7 +124,7 @@ Use `code` in your error handling. The text in `detail` can change. | `400` | Varies, with `"type": "validation_error"` | The body isn't valid. For example, `to` is missing or isn't an email address. `attr` names the field. | Fix the request. | | `400` | `invalid_recipient` | The `to` address contains `{` or `}`. | Send a plain email address. | | `400` | `invalid_idempotency_key` | The `Idempotency-Key` header isn't 1 to 255 visible ASCII characters. | Use a valid key. | -| `400` | `template_not_transactional` | The template isn't transactional. | [Make the template transactional](#make-a-template-transactional). | +| `400` | `template_not_transactional` | The template isn't transactional. | [Make the template transactional](#create-a-transactional-email). | | `400` | `template_not_sendable` | The template has no subject or no body. | Add them and publish the template. | | `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. | diff --git a/contents/docs/workflows/transactional-emails/node.mdx b/contents/docs/workflows/transactional-emails/node.mdx index e0efe0d9421f..3bef4e214b77 100644 --- a/contents/docs/workflows/transactional-emails/node.mdx +++ b/contents/docs/workflows/transactional-emails/node.mdx @@ -4,7 +4,7 @@ sidebar: Docs showTitle: true --- -The [Node.js SDK](/docs/libraries/node) sends transactional emails from version 5.61.0. Before you start, [make a template transactional](/docs/workflows/transactional-emails#make-a-template-transactional) and [create a project secret API key](/docs/workflows/transactional-emails#create-an-api-key). +The [Node.js SDK](/docs/libraries/node) sends transactional emails from version 5.61.0. Before you start, [create a transactional email](/docs/workflows/transactional-emails#create-a-transactional-email) and [create a project secret API key](/docs/workflows/transactional-emails#create-an-api-key). ## Install diff --git a/contents/docs/workflows/transactional-emails/python.mdx b/contents/docs/workflows/transactional-emails/python.mdx index 8a302011f17e..a2786de96e8b 100644 --- a/contents/docs/workflows/transactional-emails/python.mdx +++ b/contents/docs/workflows/transactional-emails/python.mdx @@ -4,7 +4,7 @@ sidebar: Docs showTitle: true --- -The [Python SDK](/docs/libraries/python) sends transactional emails from version 7.81.0. Before you start, [make a template transactional](/docs/workflows/transactional-emails#make-a-template-transactional) and [create a project secret API key](/docs/workflows/transactional-emails#create-an-api-key). +The [Python SDK](/docs/libraries/python) sends transactional emails from version 7.81.0. Before you start, [create a transactional email](/docs/workflows/transactional-emails#create-a-transactional-email) and [create a project secret API key](/docs/workflows/transactional-emails#create-an-api-key). ## Install From d55c648ae88def1d663a18660fe662c7b2e9c168 Mon Sep 17 00:00:00 2001 From: Harley Alexander <43975092+mayteio@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:31:15 +0000 Subject: [PATCH 07/26] docs(workflows): mention the per-send max_retries in Python Co-Authored-By: Claude Opus 5.5 Generated-By: PostHog Desktop Task-Id: e72a2c10-f145-4f7c-8754-a4fcc06a18be --- contents/docs/workflows/transactional-emails/python.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contents/docs/workflows/transactional-emails/python.mdx b/contents/docs/workflows/transactional-emails/python.mdx index a2786de96e8b..1ea0bf43f4bc 100644 --- a/contents/docs/workflows/transactional-emails/python.mdx +++ b/contents/docs/workflows/transactional-emails/python.mdx @@ -90,7 +90,7 @@ async def send_password_reset(user_id: str, email: str, first_name: str, reset_l Each request times out after 15 seconds. The client retries network errors, rate limits, an in-flight idempotency key, and a `503` up to two times, with the same idempotency key. It waits about 1 and then 2 seconds, or longer if the server's `Retry-After` asks for it. A `Retry-After` over 30 seconds returns the error at once. -To change this, set `email_request_timeout_seconds` and `email_request_max_retries` on the client. When a job queue already retries the send, set `email_request_max_retries=0` and pass an `idempotency_key` that identifies the email. +To change this, set `email_request_timeout_seconds` and `email_request_max_retries` on the client, or `max_retries` on one send. When a job queue already retries the send, set the retries to `0` and pass an `idempotency_key` that identifies the email. ## Check an email's status From 38f138c4235b580778bd27d1c43ebee331595cd9 Mon Sep 17 00:00:00 2001 From: Harley Alexander <43975092+mayteio@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:30:52 +0000 Subject: [PATCH 08/26] docs(workflows): send template_id and distinct_id at the top of the body The send endpoint now takes a flat body (PostHog/posthog#126287), so the field table, the curl example and the engagement events note use template_id and distinct_id. Co-Authored-By: Claude Opus 5.5 Generated-By: PostHog Desktop Task-Id: e72a2c10-f145-4f7c-8754-a4fcc06a18be --- contents/docs/workflows/transactional-emails/api.mdx | 10 +++++----- contents/docs/workflows/transactional-emails/index.mdx | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/contents/docs/workflows/transactional-emails/api.mdx b/contents/docs/workflows/transactional-emails/api.mdx index bece15c735b4..9f88ac03256b 100644 --- a/contents/docs/workflows/transactional-emails/api.mdx +++ b/contents/docs/workflows/transactional-emails/api.mdx @@ -31,10 +31,10 @@ Send a JSON body with these fields: | Field | Required | Description | | --- | --- | --- | -| `template.id` | Yes | The ID of the transactional template to send. | +| `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. | -| `identifiers.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. 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. | In the template, use the same names that you send in `message_data`: @@ -65,14 +65,14 @@ curl -X POST "https://us.posthog.com/api/projects/@current/messaging/send/email/ -H "Content-Type: application/json" \ -H "Idempotency-Key: password-reset-8f14e45f" \ -d '{ - "template": { "id": "" }, + "template_id": "", "to": "jane@example.com", - "identifiers": { "distinct_id": "user_123" }, + "distinct_id": "user_123", "message_data": { "first_name": "Jane", "reset_link": "https://example.com/reset?token=abc123" } }' ``` -The older route, `POST /api/projects/@current/messaging_templates/:template_id/send/`, takes the same body without `template` and still works. +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. diff --git a/contents/docs/workflows/transactional-emails/index.mdx b/contents/docs/workflows/transactional-emails/index.mdx index 3e052b3aee11..59db8c114a73 100644 --- a/contents/docs/workflows/transactional-emails/index.mdx +++ b/contents/docs/workflows/transactional-emails/index.mdx @@ -186,5 +186,5 @@ The lookup returns `404` with the code `not_found` when: - **Suppression still applies.** PostHog doesn't send to an address on your suppression list. - **Billing and quotas.** Transactional emails are billed and quota limited like other workflow emails. See [pricing](/pricing) and [billing limits](/docs/billing/limits-alerts). - **Sending allowance.** Transactional emails count toward your project's [sending allowance](/docs/workflows/sending-reputation). When your project reaches its hourly or daily cap, emails wait in the queue until the allowance frees up. If a transactional email gets too many bounces or complaints, PostHog can pause its sending, and sends return `sending_paused`. -- **Engagement events.** If you [capture email engagement events](/docs/workflows/engagement-events), PostHog records them for transactional emails against the person in `identifiers.distinct_id`. +- **Engagement events.** If you [capture email engagement events](/docs/workflows/engagement-events), PostHog records them for transactional emails against the person whose distinct ID you send. - **Where to see sends.** [Read the status](#check-an-emails-status) of an email with its `message_id`. The template's **Sends** tab lists each send with its log messages. The **Metrics** tab shows their metrics. Each send also appears on the recipient's person. From 76ad37a3b13b7160b69b2ebb992f108e455b1da9 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sat, 31 Oct 2026 09:50:23 +0000 Subject: [PATCH 09/26] docs(workflows): send content from your code on the transactional email API page Document the code form of the send route (template_key with the subject and body), capture_engagement, the checks that only apply to it, and a delivery_unavailable anchor for the 503's docs link. Co-Authored-By: Claude Opus 5.5 --- .../workflows/transactional-emails/api.mdx | 61 ++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/contents/docs/workflows/transactional-emails/api.mdx b/contents/docs/workflows/transactional-emails/api.mdx index 9f88ac03256b..cd3f80c9c102 100644 --- a/contents/docs/workflows/transactional-emails/api.mdx +++ b/contents/docs/workflows/transactional-emails/api.mdx @@ -74,7 +74,66 @@ 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. + +## 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 email templates live in your codebase. 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 (`_`). 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. We recommend that you always send it. | +| `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 `security@example.com` or `Hedgebox `, 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. | +| `cc`, `bcc` | No | Lists of up to 10 addresses each, without a display name. | +| `headers` | No | An object of up to 10 custom headers, such as `{ "X-Entity-Ref-ID": "order-123" }`. | +| `tags` | No | A list of up to 10 `{ "name", "value" }` objects. PostHog adds them to the email's engagement events as `$email_tags`. | +| `attachments` | No | A list of up to 10 `{ "filename", "content", "content_type", "content_id" }` objects, with `content` in Base64. The files can have at most 512 KB in total after decoding. | +| `capture_engagement` | No | Whether PostHog captures the email's [engagement events](/docs/workflows/engagement-events). For content from your code, the default is `true`. Send `false` to turn capture off for this email. | + +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 the fields from `from` to `attachments`, because the template uses its own settings. + +- **Headers.** PostHog sets some headers itself and refuses them in `headers`: `From`, `To`, `Cc`, `Bcc`, `Reply-To`, `Subject`, `Date`, `Message-ID`, `MIME-Version`, `Sender`, `Return-Path`, `Auto-Submitted`, and all `Content-*`, `X-PostHog-*`, and `X-SES-*` headers. Header names ignore case, so set each name one time. +- **Tags.** A tag name and its value use only ASCII letters, numbers, `_`, and `-`. Names that start with `ph_` belong to PostHog. +- **Attachments.** PostHog refuses file types that mailbox providers block, such as `.exe`. To show a file in the HTML, give it a `content_id`, such as `logo`, and use ``. For a larger file, send a link. +- **Suppressed addresses.** If `to`, a `cc`, or a `bcc` 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. + +```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": "jane@example.com", + "distinct_id": "user_123", + "subject": "Reset your password", + "html": "

Hi Jane,

Reset your password.

", + "from": "Hedgebox " + }' +``` + +### 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` | `invalid_request` | A field is missing or breaks a rule above, or PostHog doesn't take the field. `attr` names the field, such as `tags__0__name`. | Fix the field. If the field comes from a newer SDK, update PostHog or leave the field out. | +| `400` | `sender_not_verified` | The request has no `from`, and the project has no verified default sender. | Send `from` with an address on a verified domain, or [set a default sender](/docs/workflows/configure-channels). | +| `400` | `sender_not_allowed` | No verified sender of the project can send from the `from` address. `attr` is `from`. | Send from an address on a verified domain, or leave out `from` to use the default sender. | + ## Check an email's status From 831841b733771d20a1002c8a22c5b9c74802a9bd Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sun, 1 Nov 2026 13:01:21 +0000 Subject: [PATCH 10/26] docs(workflows): list every reserved header and the header and tag limits Add DKIM-Signature and Received to the reserved headers, the header name and value limits, the tag length, and that reply_to takes bare addresses. Co-Authored-By: Claude Opus 5.5 --- contents/docs/workflows/transactional-emails/api.mdx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/contents/docs/workflows/transactional-emails/api.mdx b/contents/docs/workflows/transactional-emails/api.mdx index cd3f80c9c102..eac7e3954826 100644 --- a/contents/docs/workflows/transactional-emails/api.mdx +++ b/contents/docs/workflows/transactional-emails/api.mdx @@ -93,7 +93,7 @@ You can also send the subject and the body from your code, without a template in | `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 `security@example.com` or `Hedgebox `, 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. | +| `reply_to` | No | A list of up to 10 addresses that replies go to, without a display name. | | `cc`, `bcc` | No | Lists of up to 10 addresses each, without a display name. | | `headers` | No | An object of up to 10 custom headers, such as `{ "X-Entity-Ref-ID": "order-123" }`. | | `tags` | No | A list of up to 10 `{ "name", "value" }` objects. PostHog adds them to the email's engagement events as `$email_tags`. | @@ -102,8 +102,8 @@ You can also send the subject and the body from your code, without a template in 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 the fields from `from` to `attachments`, because the template uses its own settings. -- **Headers.** PostHog sets some headers itself and refuses them in `headers`: `From`, `To`, `Cc`, `Bcc`, `Reply-To`, `Subject`, `Date`, `Message-ID`, `MIME-Version`, `Sender`, `Return-Path`, `Auto-Submitted`, and all `Content-*`, `X-PostHog-*`, and `X-SES-*` headers. Header names ignore case, so set each name one time. -- **Tags.** A tag name and its value use only ASCII letters, numbers, `_`, and `-`. Names that start with `ph_` belong to PostHog. +- **Headers.** PostHog sets some headers itself and refuses them in `headers`: `From`, `To`, `Cc`, `Bcc`, `Reply-To`, `Subject`, `Date`, `Message-ID`, `MIME-Version`, `Sender`, `Return-Path`, `Auto-Submitted`, `DKIM-Signature`, `Received`, and all `Content-*`, `X-PostHog-*`, and `X-SES-*` headers. Header names ignore case, so set each name one time. A name has at most 126 printable ASCII characters and no colon (`:`). A value has at most 870 printable ASCII characters. +- **Tags.** A tag name and its value use only ASCII letters, numbers, `_`, and `-`, with at most 256 characters each. Names that start with `ph_` belong to PostHog. - **Attachments.** PostHog refuses file types that mailbox providers block, such as `.exe`. To show a file in the HTML, give it a `content_id`, such as `logo`, and use ``. For a larger file, send a link. - **Suppressed addresses.** If `to`, a `cc`, or a `bcc` address is on the [suppression list](/docs/workflows/opt-outs#the-suppression-list), PostHog sends nothing and returns `recipient_suppressed`. From b42086e4cbdb7ddd86185064f8c8bbb8679f07a3 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sun, 1 Nov 2026 13:08:50 +0000 Subject: [PATCH 11/26] docs(workflows): say that a header name has no spaces Co-Authored-By: Claude Opus 5.5 --- contents/docs/workflows/transactional-emails/api.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contents/docs/workflows/transactional-emails/api.mdx b/contents/docs/workflows/transactional-emails/api.mdx index eac7e3954826..226944df6c1a 100644 --- a/contents/docs/workflows/transactional-emails/api.mdx +++ b/contents/docs/workflows/transactional-emails/api.mdx @@ -102,7 +102,7 @@ You can also send the subject and the body from your code, without a template in 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 the fields from `from` to `attachments`, because the template uses its own settings. -- **Headers.** PostHog sets some headers itself and refuses them in `headers`: `From`, `To`, `Cc`, `Bcc`, `Reply-To`, `Subject`, `Date`, `Message-ID`, `MIME-Version`, `Sender`, `Return-Path`, `Auto-Submitted`, `DKIM-Signature`, `Received`, and all `Content-*`, `X-PostHog-*`, and `X-SES-*` headers. Header names ignore case, so set each name one time. A name has at most 126 printable ASCII characters and no colon (`:`). A value has at most 870 printable ASCII characters. +- **Headers.** PostHog sets some headers itself and refuses them in `headers`: `From`, `To`, `Cc`, `Bcc`, `Reply-To`, `Subject`, `Date`, `Message-ID`, `MIME-Version`, `Sender`, `Return-Path`, `Auto-Submitted`, `DKIM-Signature`, `Received`, and all `Content-*`, `X-PostHog-*`, and `X-SES-*` headers. Header names ignore case, so set each name one time. A name has at most 126 ASCII characters, with no spaces and no colon (`:`). A value has at most 870 printable ASCII characters, and it can have spaces. - **Tags.** A tag name and its value use only ASCII letters, numbers, `_`, and `-`, with at most 256 characters each. Names that start with `ph_` belong to PostHog. - **Attachments.** PostHog refuses file types that mailbox providers block, such as `.exe`. To show a file in the HTML, give it a `content_id`, such as `logo`, and use ``. For a larger file, send a link. - **Suppressed addresses.** If `to`, a `cc`, or a `bcc` address is on the [suppression list](/docs/workflows/opt-outs#the-suppression-list), PostHog sends nothing and returns `recipient_suppressed`. From acd4a4b88da1f2d619a84a22a85f968d3ac658e6 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sun, 1 Nov 2026 13:10:22 +0000 Subject: [PATCH 12/26] docs(workflows): say that a header name has visible ASCII characters only Co-Authored-By: Claude Opus 5.5 --- contents/docs/workflows/transactional-emails/api.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contents/docs/workflows/transactional-emails/api.mdx b/contents/docs/workflows/transactional-emails/api.mdx index 226944df6c1a..cc79aada9cdd 100644 --- a/contents/docs/workflows/transactional-emails/api.mdx +++ b/contents/docs/workflows/transactional-emails/api.mdx @@ -102,7 +102,7 @@ You can also send the subject and the body from your code, without a template in 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 the fields from `from` to `attachments`, because the template uses its own settings. -- **Headers.** PostHog sets some headers itself and refuses them in `headers`: `From`, `To`, `Cc`, `Bcc`, `Reply-To`, `Subject`, `Date`, `Message-ID`, `MIME-Version`, `Sender`, `Return-Path`, `Auto-Submitted`, `DKIM-Signature`, `Received`, and all `Content-*`, `X-PostHog-*`, and `X-SES-*` headers. Header names ignore case, so set each name one time. A name has at most 126 ASCII characters, with no spaces and no colon (`:`). A value has at most 870 printable ASCII characters, and it can have spaces. +- **Headers.** PostHog sets some headers itself and refuses them in `headers`: `From`, `To`, `Cc`, `Bcc`, `Reply-To`, `Subject`, `Date`, `Message-ID`, `MIME-Version`, `Sender`, `Return-Path`, `Auto-Submitted`, `DKIM-Signature`, `Received`, and all `Content-*`, `X-PostHog-*`, and `X-SES-*` headers. Header names ignore case, so set each name one time. A name has 1 to 126 visible ASCII characters, with no spaces and no colon (`:`). A value has at most 870 printable ASCII characters, and it can have spaces. - **Tags.** A tag name and its value use only ASCII letters, numbers, `_`, and `-`, with at most 256 characters each. Names that start with `ph_` belong to PostHog. - **Attachments.** PostHog refuses file types that mailbox providers block, such as `.exe`. To show a file in the HTML, give it a `content_id`, such as `logo`, and use ``. For a larger file, send a link. - **Suppressed addresses.** If `to`, a `cc`, or a `bcc` address is on the [suppression list](/docs/workflows/opt-outs#the-suppression-list), PostHog sends nothing and returns `recipient_suppressed`. From 2502eb65a91065a2c73656d1913fe478c9c3a765 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:43:31 +0000 Subject: [PATCH 13/26] docs(workflows): say what makes a code send the same request, and what template_key and ph_ tags do - A retry with the same Idempotency-Key is the same request when every field of the code-form table matches. - template_key groups the email's metrics and sends. - A tag name that starts with ph_, in any case, is refused. - capture_engagement on a template send defaults to the project's setting. - Drop a double blank line after the checks table (fix-mdx). Co-Authored-By: Claude Opus 5.5 --- contents/docs/workflows/transactional-emails/api.mdx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/contents/docs/workflows/transactional-emails/api.mdx b/contents/docs/workflows/transactional-emails/api.mdx index cc79aada9cdd..ed54378852a8 100644 --- a/contents/docs/workflows/transactional-emails/api.mdx +++ b/contents/docs/workflows/transactional-emails/api.mdx @@ -86,7 +86,7 @@ You can also send the subject and the body from your code, without a template in | 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 (`_`). Use the same key every time you send this email. | +| `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. We recommend that you always send it. | | `subject` | Yes | The subject of the email. | @@ -98,16 +98,16 @@ You can also send the subject and the body from your code, without a template in | `headers` | No | An object of up to 10 custom headers, such as `{ "X-Entity-Ref-ID": "order-123" }`. | | `tags` | No | A list of up to 10 `{ "name", "value" }` objects. PostHog adds them to the email's engagement events as `$email_tags`. | | `attachments` | No | A list of up to 10 `{ "filename", "content", "content_type", "content_id" }` objects, with `content` in Base64. The files can have at most 512 KB in total after decoding. | -| `capture_engagement` | No | Whether PostHog captures the email's [engagement events](/docs/workflows/engagement-events). For content from your code, the default is `true`. Send `false` to turn capture off for this email. | +| `capture_engagement` | No | Whether PostHog captures the email's [engagement events](/docs/workflows/engagement-events). For content from your code, the default is `true`. For a template send, the default is the project's **Capture email engagement events** setting. Send `false` to turn capture off for this email. | 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 the fields from `from` to `attachments`, because the template uses its own settings. - **Headers.** PostHog sets some headers itself and refuses them in `headers`: `From`, `To`, `Cc`, `Bcc`, `Reply-To`, `Subject`, `Date`, `Message-ID`, `MIME-Version`, `Sender`, `Return-Path`, `Auto-Submitted`, `DKIM-Signature`, `Received`, and all `Content-*`, `X-PostHog-*`, and `X-SES-*` headers. Header names ignore case, so set each name one time. A name has 1 to 126 visible ASCII characters, with no spaces and no colon (`:`). A value has at most 870 printable ASCII characters, and it can have spaces. -- **Tags.** A tag name and its value use only ASCII letters, numbers, `_`, and `-`, with at most 256 characters each. Names that start with `ph_` belong to PostHog. +- **Tags.** A tag name and its value use only ASCII letters, numbers, `_`, and `-`, with at most 256 characters each. PostHog refuses a name that starts with `ph_`, in any case, because those names are PostHog's. - **Attachments.** PostHog refuses file types that mailbox providers block, such as `.exe`. To show a file in the HTML, give it a `content_id`, such as `logo`, and use ``. For a larger file, send a link. - **Suppressed addresses.** If `to`, a `cc`, or a `bcc` 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. +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. ```bash curl -X POST "https://us.posthog.com/api/projects/@current/messaging/send/email/" \ @@ -134,7 +134,6 @@ These checks apply to content from your code, in addition to the [errors](/docs/ | `400` | `sender_not_verified` | The request has no `from`, and the project has no verified default sender. | Send `from` with an address on a verified domain, or [set a default sender](/docs/workflows/configure-channels). | | `400` | `sender_not_allowed` | No verified sender of the project can send from the `from` address. `attr` is `from`. | Send from an address on a verified domain, or leave out `from` to use the default sender. | - ## Check an email's status To find out what happened to an email, read its status with the `message_id` from the send: From 673346e756100761801ca282cce9dc1bc673c984 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:45:54 +0000 Subject: [PATCH 14/26] docs(workflows): list capture_engagement on the template send, and spell out the ph_ case rule - The template-send table gets a capture_engagement row with the project default, and says a retry sends the field the same way. - The code-form row keeps only its own default. - A ph_ tag name is refused in uppercase or lowercase. Co-Authored-By: Claude Opus 5.5 --- contents/docs/workflows/transactional-emails/api.mdx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/contents/docs/workflows/transactional-emails/api.mdx b/contents/docs/workflows/transactional-emails/api.mdx index ed54378852a8..6cee0e4b97d6 100644 --- a/contents/docs/workflows/transactional-emails/api.mdx +++ b/contents/docs/workflows/transactional-emails/api.mdx @@ -35,6 +35,7 @@ Send a JSON body with these fields: | `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. | +| `capture_engagement` | No | Whether PostHog captures the email's [engagement events](/docs/workflows/engagement-events). The default is the project's **Capture email engagement events** setting. Send `false` to turn capture off for this email. On a retry with the same `Idempotency-Key`, send this field as you did the first time, or PostHog returns `idempotency_key_reused`. | In the template, use the same names that you send in `message_data`: @@ -98,12 +99,12 @@ You can also send the subject and the body from your code, without a template in | `headers` | No | An object of up to 10 custom headers, such as `{ "X-Entity-Ref-ID": "order-123" }`. | | `tags` | No | A list of up to 10 `{ "name", "value" }` objects. PostHog adds them to the email's engagement events as `$email_tags`. | | `attachments` | No | A list of up to 10 `{ "filename", "content", "content_type", "content_id" }` objects, with `content` in Base64. The files can have at most 512 KB in total after decoding. | -| `capture_engagement` | No | Whether PostHog captures the email's [engagement events](/docs/workflows/engagement-events). For content from your code, the default is `true`. For a template send, the default is the project's **Capture email engagement events** setting. Send `false` to turn capture off for this email. | +| `capture_engagement` | No | Whether PostHog captures the email's [engagement events](/docs/workflows/engagement-events). For content from your code, the default is `true`. Send `false` to turn capture off for this email. | 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 the fields from `from` to `attachments`, because the template uses its own settings. - **Headers.** PostHog sets some headers itself and refuses them in `headers`: `From`, `To`, `Cc`, `Bcc`, `Reply-To`, `Subject`, `Date`, `Message-ID`, `MIME-Version`, `Sender`, `Return-Path`, `Auto-Submitted`, `DKIM-Signature`, `Received`, and all `Content-*`, `X-PostHog-*`, and `X-SES-*` headers. Header names ignore case, so set each name one time. A name has 1 to 126 visible ASCII characters, with no spaces and no colon (`:`). A value has at most 870 printable ASCII characters, and it can have spaces. -- **Tags.** A tag name and its value use only ASCII letters, numbers, `_`, and `-`, with at most 256 characters each. PostHog refuses a name that starts with `ph_`, in any case, because those names are PostHog's. +- **Tags.** A tag name and its value use only ASCII letters, numbers, `_`, and `-`, with at most 256 characters each. PostHog refuses a name that starts with `ph_` in uppercase or lowercase, such as `PH_` or `Ph_`, because those names are PostHog's. - **Attachments.** PostHog refuses file types that mailbox providers block, such as `.exe`. To show a file in the HTML, give it a `content_id`, such as `logo`, and use ``. For a larger file, send a link. - **Suppressed addresses.** If `to`, a `cc`, or a `bcc` address is on the [suppression list](/docs/workflows/opt-outs#the-suppression-list), PostHog sends nothing and returns `recipient_suppressed`. From 3f33a2577a49c1a61e7e96262876aae0eb07c1d2 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 2 Nov 2026 15:00:57 +0000 Subject: [PATCH 15/26] docs(workflows): cut cc, bcc, headers, tags and attachments from the code send The first release sends content from code with template_key, subject, html and text only. React Email output is rendered by the app and sent as html. Co-Authored-By: Claude Opus 5.5 --- .../docs/workflows/transactional-emails/api.mdx | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/contents/docs/workflows/transactional-emails/api.mdx b/contents/docs/workflows/transactional-emails/api.mdx index 6cee0e4b97d6..9283242b380a 100644 --- a/contents/docs/workflows/transactional-emails/api.mdx +++ b/contents/docs/workflows/transactional-emails/api.mdx @@ -83,7 +83,7 @@ A `503` with the code `delivery_unavailable` means that PostHog couldn't confirm ## 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 email templates live in your codebase. 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). +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 | | --- | --- | --- | @@ -95,18 +95,11 @@ You can also send the subject and the body from your code, without a template in | `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 `security@example.com` or `Hedgebox `, 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. | -| `cc`, `bcc` | No | Lists of up to 10 addresses each, without a display name. | -| `headers` | No | An object of up to 10 custom headers, such as `{ "X-Entity-Ref-ID": "order-123" }`. | -| `tags` | No | A list of up to 10 `{ "name", "value" }` objects. PostHog adds them to the email's engagement events as `$email_tags`. | -| `attachments` | No | A list of up to 10 `{ "filename", "content", "content_type", "content_id" }` objects, with `content` in Base64. The files can have at most 512 KB in total after decoding. | | `capture_engagement` | No | Whether PostHog captures the email's [engagement events](/docs/workflows/engagement-events). For content from your code, the default is `true`. Send `false` to turn capture off for this email. | -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 the fields from `from` to `attachments`, because the template uses its own settings. +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 no `from` or `reply_to`, because the template uses its own settings. -- **Headers.** PostHog sets some headers itself and refuses them in `headers`: `From`, `To`, `Cc`, `Bcc`, `Reply-To`, `Subject`, `Date`, `Message-ID`, `MIME-Version`, `Sender`, `Return-Path`, `Auto-Submitted`, `DKIM-Signature`, `Received`, and all `Content-*`, `X-PostHog-*`, and `X-SES-*` headers. Header names ignore case, so set each name one time. A name has 1 to 126 visible ASCII characters, with no spaces and no colon (`:`). A value has at most 870 printable ASCII characters, and it can have spaces. -- **Tags.** A tag name and its value use only ASCII letters, numbers, `_`, and `-`, with at most 256 characters each. PostHog refuses a name that starts with `ph_` in uppercase or lowercase, such as `PH_` or `Ph_`, because those names are PostHog's. -- **Attachments.** PostHog refuses file types that mailbox providers block, such as `.exe`. To show a file in the HTML, give it a `content_id`, such as `logo`, and use ``. For a larger file, send a link. -- **Suppressed addresses.** If `to`, a `cc`, or a `bcc` address is on the [suppression list](/docs/workflows/opt-outs#the-suppression-list), PostHog sends nothing and returns `recipient_suppressed`. +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. @@ -131,7 +124,7 @@ These checks apply to content from your code, in addition to the [errors](/docs/ | Status | Code | Cause | What to do | | --- | --- | --- | --- | -| `400` | `invalid_request` | A field is missing or breaks a rule above, or PostHog doesn't take the field. `attr` names the field, such as `tags__0__name`. | Fix the field. If the field comes from a newer SDK, update PostHog or leave the field out. | +| `400` | `invalid_request` | A field is missing or breaks a rule above, or PostHog doesn't take the field. `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` | The request has no `from`, and the project has no verified default sender. | Send `from` with an address on a verified domain, or [set a default sender](/docs/workflows/configure-channels). | | `400` | `sender_not_allowed` | No verified sender of the project can send from the `from` address. `attr` is `from`. | Send from an address on a verified domain, or leave out `from` to use the default sender. | From 7295a66a7e8f052fea70701e50ec97d447fd9981 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 2 Nov 2026 15:49:27 +0000 Subject: [PATCH 16/26] docs(workflows): match the code form to the first send release - Drop capture_engagement from the template send: the send route refuses it there, with the other fields of content from your code. - Fold sender_not_allowed into sender_not_verified, the code the first release returns for a from address no verified sender can use. - Add "Send content from your code" to the Node.js and Python pages, with a React Email example rendered by the app. Co-Authored-By: Claude Opus 5.5 --- .../workflows/transactional-emails/api.mdx | 8 ++-- .../workflows/transactional-emails/node.mdx | 36 +++++++++++++++ .../workflows/transactional-emails/python.mdx | 44 +++++++++++++++++++ 3 files changed, 83 insertions(+), 5 deletions(-) diff --git a/contents/docs/workflows/transactional-emails/api.mdx b/contents/docs/workflows/transactional-emails/api.mdx index 9283242b380a..f9460c8c8ad4 100644 --- a/contents/docs/workflows/transactional-emails/api.mdx +++ b/contents/docs/workflows/transactional-emails/api.mdx @@ -35,7 +35,6 @@ Send a JSON body with these fields: | `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. | -| `capture_engagement` | No | Whether PostHog captures the email's [engagement events](/docs/workflows/engagement-events). The default is the project's **Capture email engagement events** setting. Send `false` to turn capture off for this email. On a retry with the same `Idempotency-Key`, send this field as you did the first time, or PostHog returns `idempotency_key_reused`. | In the template, use the same names that you send in `message_data`: @@ -95,9 +94,9 @@ You can also send the subject and the body from your code, without a template in | `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 `security@example.com` or `Hedgebox `, 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). For content from your code, the default is `true`. Send `false` to turn capture off for this email. | +| `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 no `from` or `reply_to`, because the template uses its own settings. +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 `invalid_request` for each of them. If the `to` address is on the [suppression list](/docs/workflows/opt-outs#the-suppression-list), PostHog sends nothing and returns `recipient_suppressed`. @@ -125,8 +124,7 @@ These checks apply to content from your code, in addition to the [errors](/docs/ | Status | Code | Cause | What to do | | --- | --- | --- | --- | | `400` | `invalid_request` | A field is missing or breaks a rule above, or PostHog doesn't take the field. `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` | The request has no `from`, and the project has no verified default sender. | Send `from` with an address on a verified domain, or [set a default sender](/docs/workflows/configure-channels). | -| `400` | `sender_not_allowed` | No verified sender of the project can send from the `from` address. `attr` is `from`. | Send from an address on a verified domain, or leave out `from` to use the default sender. | +| `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 diff --git a/contents/docs/workflows/transactional-emails/node.mdx b/contents/docs/workflows/transactional-emails/node.mdx index 7c47deb2d655..73ac01672691 100644 --- a/contents/docs/workflows/transactional-emails/node.mdx +++ b/contents/docs/workflows/transactional-emails/node.mdx @@ -58,6 +58,42 @@ 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. +## 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() + const { data, error } = await posthog.emails.send({ + to: email, + templateKey: "password-reset", + subject: "Reset your password", + html, + from: "Hedgebox ", + 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 diff --git a/contents/docs/workflows/transactional-emails/python.mdx b/contents/docs/workflows/transactional-emails/python.mdx index e017761d6f33..ad4a938fdfa6 100644 --- a/contents/docs/workflows/transactional-emails/python.mdx +++ b/contents/docs/workflows/transactional-emails/python.mdx @@ -92,6 +92,50 @@ Each request times out after 15 seconds. The client retries network errors, rate To change this, set `email_request_timeout_seconds` and `email_request_max_retries` on the client, or `max_retries` on one send. When a job queue already retries the send, set the retries to `0` and pass an `idempotency_key` that identifies the email. +## Send content from your code + +From version 7.83.0, `send` also takes content that your code rendered, without a template in PostHog. Name the email with `template_key`, and pass `subject` and `html`, `text`, or both. PostHog sends the content as you wrote it, so render it with your template engine first, such as Jinja or Django templates. + + + +```python file=Sync +def send_password_reset(user_id: str, email: str, html: str, reset_request_id: str) -> str: + result = posthog.emails.send( + to=email, + template_key="password-reset", + subject="Reset your password", + html=html, + from_="Hedgebox ", + distinct_id=user_id, + idempotency_key=f"password-reset-{reset_request_id}", + ) + return result.id +``` + +```python file=Async +async def send_password_reset(user_id: str, email: str, html: str, reset_request_id: str) -> str: + result = await posthog.emails.send( + to=email, + template_key="password-reset", + subject="Reset your password", + html=html, + from_="Hedgebox ", + distinct_id=user_id, + idempotency_key=f"password-reset-{reset_request_id}", + ) + return result.id +``` + + + +- **Key:** `template_key` 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:** The sender is `from_`, because `from` is a Python keyword. It needs an address on a domain that your project verified. Without it, PostHog uses the project's default sender. `reply_to` takes one address or a list. +- **Engagement:** PostHog captures the email's engagement events unless you pass `capture_engagement=False`. +- **One form per send:** A send has `template_id` or `template_key`, not both, or it raises `TypeError`. Content from your code takes no `template_data`, 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 ```python From 2610e8d1d1db1b36c8b02308196ffd851c2a277d Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 2 Nov 2026 15:57:19 +0000 Subject: [PATCH 17/26] docs(workflows): name the first release's field errors and the sender in a replay - A field error is a 400 with type validation_error, a field code such as required or invalid, and attr naming the field. - A replay also compares the sender PostHog chose, so a retry without from after the default sender changed returns idempotency_key_reused. Co-Authored-By: Claude Opus 5.5 --- contents/docs/workflows/transactional-emails/api.mdx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/contents/docs/workflows/transactional-emails/api.mdx b/contents/docs/workflows/transactional-emails/api.mdx index f9460c8c8ad4..f7a591cbb273 100644 --- a/contents/docs/workflows/transactional-emails/api.mdx +++ b/contents/docs/workflows/transactional-emails/api.mdx @@ -96,11 +96,11 @@ You can also send the subject and the body from your code, without a template in | `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 `invalid_request` for each of them. +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. +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/" \ @@ -123,7 +123,7 @@ These checks apply to content from your code, in addition to the [errors](/docs/ | Status | Code | Cause | What to do | | --- | --- | --- | --- | -| `400` | `invalid_request` | A field is missing or breaks a rule above, or PostHog doesn't take the field. `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` | 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 From 5cffbd04565900445573f83bf20c5dba0ec1d2d5 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 3 Nov 2026 11:31:06 +0000 Subject: [PATCH 18/26] docs(workflows): link transactional emails to your users with the identify distinct ID Add "Link emails to your users" to the API, Node.js and Python pages: pass the distinct ID you pass to identify(), what happens without one today (a separate person keyed by the email address), and that invites and signup checks can leave it out. The overview and the API tables point there instead of "always send it". Co-Authored-By: Claude Opus 5.5 --- contents/docs/workflows/transactional-emails/api.mdx | 11 +++++++++-- .../docs/workflows/transactional-emails/index.mdx | 2 +- contents/docs/workflows/transactional-emails/node.mdx | 7 +++++++ .../docs/workflows/transactional-emails/python.mdx | 7 +++++++ 4 files changed, 24 insertions(+), 3 deletions(-) diff --git a/contents/docs/workflows/transactional-emails/api.mdx b/contents/docs/workflows/transactional-emails/api.mdx index f7a591cbb273..599e98a8efac 100644 --- a/contents/docs/workflows/transactional-emails/api.mdx +++ b/contents/docs/workflows/transactional-emails/api.mdx @@ -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`: @@ -80,6 +80,13 @@ When a send fails, the response carries a `code`. See [errors](/docs/workflows/t 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 user's ID in your app, 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 a check during signup, 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). @@ -88,7 +95,7 @@ You can also send the subject and the body from your code, without a template in | --- | --- | --- | | `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. We recommend that you always send it. | +| `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`. | diff --git a/contents/docs/workflows/transactional-emails/index.mdx b/contents/docs/workflows/transactional-emails/index.mdx index 59db8c114a73..644e8955c0a7 100644 --- a/contents/docs/workflows/transactional-emails/index.mdx +++ b/contents/docs/workflows/transactional-emails/index.mdx @@ -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: diff --git a/contents/docs/workflows/transactional-emails/node.mdx b/contents/docs/workflows/transactional-emails/node.mdx index 73ac01672691..56fe0da0c67a 100644 --- a/contents/docs/workflows/transactional-emails/node.mdx +++ b/contents/docs/workflows/transactional-emails/node.mdx @@ -58,6 +58,13 @@ 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 user's ID in your app, 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 a check during signup, 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. diff --git a/contents/docs/workflows/transactional-emails/python.mdx b/contents/docs/workflows/transactional-emails/python.mdx index ad4a938fdfa6..4eb740128cbf 100644 --- a/contents/docs/workflows/transactional-emails/python.mdx +++ b/contents/docs/workflows/transactional-emails/python.mdx @@ -92,6 +92,13 @@ Each request times out after 15 seconds. The client retries network errors, rate To change this, set `email_request_timeout_seconds` and `email_request_max_retries` on the client, or `max_retries` on one send. When a job queue already retries the send, set the retries to `0` and pass an `idempotency_key` that identifies the email. +## Link emails to your users + +Pass the distinct ID that you pass to `identify()`, usually the user's ID in your app, 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 a check during signup, there's no distinct ID to pass. Leave `distinct_id` out. + ## Send content from your code From version 7.83.0, `send` also takes content that your code rendered, without a template in PostHog. Name the email with `template_key`, and pass `subject` and `html`, `text`, or both. PostHog sends the content as you wrote it, so render it with your template engine first, such as Jinja or Django templates. From 51f427ba61f024ec2931c02e21b0770fbc7d5206 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 3 Nov 2026 11:35:43 +0000 Subject: [PATCH 19/26] docs(workflows): say which signup email can leave out the distinct ID Co-Authored-By: Claude Opus 5.5 --- contents/docs/workflows/transactional-emails/api.mdx | 2 +- contents/docs/workflows/transactional-emails/node.mdx | 2 +- contents/docs/workflows/transactional-emails/python.mdx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/contents/docs/workflows/transactional-emails/api.mdx b/contents/docs/workflows/transactional-emails/api.mdx index 599e98a8efac..554b103433f3 100644 --- a/contents/docs/workflows/transactional-emails/api.mdx +++ b/contents/docs/workflows/transactional-emails/api.mdx @@ -85,7 +85,7 @@ A `503` with the code `delivery_unavailable` means that PostHog couldn't confirm Pass the distinct ID that you pass to `identify()`, usually the user's ID in your app, 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 a check during signup, there's no distinct ID to pass. Leave `distinct_id` out. +- **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 diff --git a/contents/docs/workflows/transactional-emails/node.mdx b/contents/docs/workflows/transactional-emails/node.mdx index 56fe0da0c67a..0f2d5cafc8fb 100644 --- a/contents/docs/workflows/transactional-emails/node.mdx +++ b/contents/docs/workflows/transactional-emails/node.mdx @@ -63,7 +63,7 @@ To change this, set `emailRequestTimeoutMs` and `emailRequestMaxRetries` on the Pass the distinct ID that you pass to `identify()`, usually the user's ID in your app, 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 a check during signup, there's no distinct ID to pass. Leave `distinctId` out. +- **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 diff --git a/contents/docs/workflows/transactional-emails/python.mdx b/contents/docs/workflows/transactional-emails/python.mdx index 4eb740128cbf..367597b9ac5a 100644 --- a/contents/docs/workflows/transactional-emails/python.mdx +++ b/contents/docs/workflows/transactional-emails/python.mdx @@ -97,7 +97,7 @@ To change this, set `email_request_timeout_seconds` and `email_request_max_retri Pass the distinct ID that you pass to `identify()`, usually the user's ID in your app, 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 a check during signup, there's no distinct ID to pass. Leave `distinct_id` out. +- **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 From c42c7c3a80d0a9c1e6976d045f54fb45fa8aa2e4 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 3 Nov 2026 11:54:17 +0000 Subject: [PATCH 20/26] docs(workflows): match the link-emails section to the docs style rules Co-Authored-By: Claude Opus 5.5 --- contents/docs/workflows/transactional-emails/api.mdx | 2 +- contents/docs/workflows/transactional-emails/node.mdx | 2 +- contents/docs/workflows/transactional-emails/python.mdx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/contents/docs/workflows/transactional-emails/api.mdx b/contents/docs/workflows/transactional-emails/api.mdx index 554b103433f3..3d112d0ba739 100644 --- a/contents/docs/workflows/transactional-emails/api.mdx +++ b/contents/docs/workflows/transactional-emails/api.mdx @@ -82,7 +82,7 @@ A `503` with the code `delivery_unavailable` means that PostHog couldn't confirm ## Link emails to your users -Pass the distinct ID that you pass to `identify()`, usually the user's ID in your app, 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. +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. diff --git a/contents/docs/workflows/transactional-emails/node.mdx b/contents/docs/workflows/transactional-emails/node.mdx index 0f2d5cafc8fb..cfb908c66e4f 100644 --- a/contents/docs/workflows/transactional-emails/node.mdx +++ b/contents/docs/workflows/transactional-emails/node.mdx @@ -60,7 +60,7 @@ To change this, set `emailRequestTimeoutMs` and `emailRequestMaxRetries` on the ## Link emails to your users -Pass the distinct ID that you pass to `identify()`, usually the user's ID in your app, 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. +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. diff --git a/contents/docs/workflows/transactional-emails/python.mdx b/contents/docs/workflows/transactional-emails/python.mdx index 367597b9ac5a..0d02c934ae8c 100644 --- a/contents/docs/workflows/transactional-emails/python.mdx +++ b/contents/docs/workflows/transactional-emails/python.mdx @@ -94,7 +94,7 @@ To change this, set `email_request_timeout_seconds` and `email_request_max_retri ## Link emails to your users -Pass the distinct ID that you pass to `identify()`, usually the user's ID in your app, 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. +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. From 01f7e5f87306e9fda6a2e25b5ea0236507974c39 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Wed, 4 Nov 2026 08:34:10 +0000 Subject: [PATCH 21/26] docs(workflows): fill the SDK examples with the reader's token and host The Node.js and Python client examples used and a hard-coded ingestion host, which the site doesn't replace. They now use and , as the SDK pages do, so a logged-in reader sees their own project token and host. Co-Authored-By: Claude Opus 5.5 --- contents/docs/workflows/transactional-emails/node.mdx | 4 ++-- contents/docs/workflows/transactional-emails/python.mdx | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/contents/docs/workflows/transactional-emails/node.mdx b/contents/docs/workflows/transactional-emails/node.mdx index cfb908c66e4f..59679f8a2647 100644 --- a/contents/docs/workflows/transactional-emails/node.mdx +++ b/contents/docs/workflows/transactional-emails/node.mdx @@ -19,8 +19,8 @@ Reuse the PostHog client your application already creates, and set `secretKey` t ```js import { PostHog } from "posthog-node" -const posthog = new PostHog("", { - host: "https://us.i.posthog.com", +const posthog = new PostHog("", { + 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, diff --git a/contents/docs/workflows/transactional-emails/python.mdx b/contents/docs/workflows/transactional-emails/python.mdx index 0d02c934ae8c..bc5c8677d7a5 100644 --- a/contents/docs/workflows/transactional-emails/python.mdx +++ b/contents/docs/workflows/transactional-emails/python.mdx @@ -26,8 +26,8 @@ import os from posthog import Posthog posthog = Posthog( - "", - host="https://us.i.posthog.com", + "", + host="", secret_key=os.environ["POSTHOG_PROJECT_SECRET_API_KEY"], ) ``` @@ -38,8 +38,8 @@ import os from posthog import AsyncPosthog posthog = AsyncPosthog( - "", - host="https://us.i.posthog.com", + "", + host="", secret_key=os.environ["POSTHOG_PROJECT_SECRET_API_KEY"], ) ``` From 86a0e4361826c2d22171918cd32e0faad8d10e34 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 5 Nov 2026 07:45:26 +0000 Subject: [PATCH 22/26] docs(workflows): name the transactional_email scopes for sends and status reads A send needs transactional_email:write and a status read needs transactional_email:read or :write. Keys with a hog_flow scope still work. Co-Authored-By: Claude Opus 5.5 --- contents/docs/workflows/transactional-emails/api.mdx | 2 +- contents/docs/workflows/transactional-emails/index.mdx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/contents/docs/workflows/transactional-emails/api.mdx b/contents/docs/workflows/transactional-emails/api.mdx index 3d112d0ba739..e109276d548c 100644 --- a/contents/docs/workflows/transactional-emails/api.mdx +++ b/contents/docs/workflows/transactional-emails/api.mdx @@ -141,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`: diff --git a/contents/docs/workflows/transactional-emails/index.mdx b/contents/docs/workflows/transactional-emails/index.mdx index 644e8955c0a7..e53bd697ccad 100644 --- a/contents/docs/workflows/transactional-emails/index.mdx +++ b/contents/docs/workflows/transactional-emails/index.mdx @@ -58,7 +58,7 @@ 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. @@ -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. | From 8d35205cfd05964574f05df9733e7ae1aae7f4b0 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 5 Nov 2026 07:49:34 +0000 Subject: [PATCH 23/26] docs(workflows): pick Transactional email in the key scopes list The key steps still picked Workflow, which creates a hog_flow key. The scopes list names the new scope Transactional email. Co-Authored-By: Claude Opus 5.5 --- contents/docs/workflows/transactional-emails/index.mdx | 4 ++-- contents/docs/workflows/transactional-emails/node.mdx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/contents/docs/workflows/transactional-emails/index.mdx b/contents/docs/workflows/transactional-emails/index.mdx index e53bd697ccad..c92a7a841e72 100644 --- a/contents/docs/workflows/transactional-emails/index.mdx +++ b/contents/docs/workflows/transactional-emails/index.mdx @@ -62,12 +62,12 @@ Send transactional emails with a [project secret API key](/docs/api/project-secr 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 diff --git a/contents/docs/workflows/transactional-emails/node.mdx b/contents/docs/workflows/transactional-emails/node.mdx index 59679f8a2647..61c20c95de5b 100644 --- a/contents/docs/workflows/transactional-emails/node.mdx +++ b/contents/docs/workflows/transactional-emails/node.mdx @@ -27,7 +27,7 @@ const posthog = new PostHog("", { }) ``` -- **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 From 54968e2b18d34b8b3b2c2273202eea4c269b4756 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 5 Nov 2026 08:10:13 +0000 Subject: [PATCH 24/26] docs(workflows): make a template transactional with the transactional email scope Co-Authored-By: Claude Opus 5.5 --- contents/docs/workflows/transactional-emails/index.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contents/docs/workflows/transactional-emails/index.mdx b/contents/docs/workflows/transactional-emails/index.mdx index c92a7a841e72..0236bedd054b 100644 --- a/contents/docs/workflows/transactional-emails/index.mdx +++ b/contents/docs/workflows/transactional-emails/index.mdx @@ -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. ### Edit a transactional email From bd172194271b887dfc031d3e291a26fba7d40758 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 5 Nov 2026 10:25:45 +0000 Subject: [PATCH 25/26] docs(workflows): say to retry a status read right after a send A status read right after a send can answer 404 for a few seconds, until the send's first status arrives. Say so where the API page lists the 404, and that a retry after a short wait is the fix. Co-Authored-By: Claude Opus 5.5 --- contents/docs/workflows/transactional-emails/api.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contents/docs/workflows/transactional-emails/api.mdx b/contents/docs/workflows/transactional-emails/api.mdx index e109276d548c..c94dbf6b7c07 100644 --- a/contents/docs/workflows/transactional-emails/api.mdx +++ b/contents/docs/workflows/transactional-emails/api.mdx @@ -157,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. From 054a8a43aacafa652dbec210b7d71c8e26c9062e Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 5 Nov 2026 10:40:42 +0000 Subject: [PATCH 26/26] docs(workflows): Python code sends start at 7.84.0 posthog-python#1171 ships as a minor bump, and PyPI is already at 7.83.2, so the code send arrives in 7.84.0, not 7.83.0. Co-Authored-By: Claude Opus 5.5 --- contents/docs/workflows/transactional-emails/python.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contents/docs/workflows/transactional-emails/python.mdx b/contents/docs/workflows/transactional-emails/python.mdx index bc5c8677d7a5..db9d12c339dd 100644 --- a/contents/docs/workflows/transactional-emails/python.mdx +++ b/contents/docs/workflows/transactional-emails/python.mdx @@ -101,7 +101,7 @@ Pass the distinct ID that you pass to `identify()`, usually the ID your app give ## Send content from your code -From version 7.83.0, `send` also takes content that your code rendered, without a template in PostHog. Name the email with `template_key`, and pass `subject` and `html`, `text`, or both. PostHog sends the content as you wrote it, so render it with your template engine first, such as Jinja or Django templates. +From version 7.84.0, `send` also takes content that your code rendered, without a template in PostHog. Name the email with `template_key`, and pass `subject` and `html`, `text`, or both. PostHog sends the content as you wrote it, so render it with your template engine first, such as Jinja or Django templates.