diff --git a/contents/docs/workflows/transactional-email-api.mdx b/contents/docs/workflows/transactional-email-api.mdx index 1f8fcfd52c4f..7f96c56f5d10 100644 --- a/contents/docs/workflows/transactional-email-api.mdx +++ b/contents/docs/workflows/transactional-email-api.mdx @@ -6,7 +6,7 @@ showTitle: true import CalloutBox from "components/Docs/CalloutBox"; -The transactional email API sends one email to one person from your server code, such as a welcome email, a signup confirmation, or a password reset. You keep the email as an HTML file in your repository. PostHog stores it as a message template, sends it through Workflows, and records delivery and engagement on the recipient's person. +The transactional email API sends one email to one person from your server code, such as a welcome email, a signup confirmation, or a password reset. You call `posthog.emails.send` on the PostHog client that you already use, in Node.js or in Python. PostHog sends the email through Workflows and records delivery and engagement on the recipient's person. @@ -16,50 +16,30 @@ The API is in beta, and PostHog enables it per project. To use it, [ask us in-ap ## How it works -1. You write the email as an HTML file with [Liquid](https://shopify.github.io/liquid/) placeholders, such as `{{ variables.first_name }}`. -2. You upload the file to PostHog under a key that you choose, such as `welcome`. The first upload creates a message template, marks it as managed by code, and makes it transactional with your project's default sender. -3. Your server sends the template by its key, with the recipient and the values for the placeholders. +A send takes one of two forms: -In Node.js, `posthog-node` does steps 2 and 3 for you. It reads the file on every send and uploads it when its content changed. In other languages, you call the [upload route](#upload-a-template-by-key) and the [send route](#send-an-email) over HTTP. +- **A template made in PostHog**, sent by its `template_id`, with `template_data` for its `{{ variables.* }}` placeholders. Edit the template in PostHog, and your code keeps sending it. +- **An email from your code**, named by a `template_key` that you choose, such as `password-reset`, with its `subject`, and `html` or `text`. The key groups the email's logs, metrics, and status in PostHog. This form has two cases: + - **Rendered content.** Your code renders the email for one recipient, for example with React Email. PostHog sends it as it is and doesn't store it as a template. + - **A local template.** Your code holds a template with `{{ variables.* }}` placeholders and sends it with `template_data`. The SDK uploads the template to PostHog on its first send and whenever its content changes. You never call an upload. -The same key works in every project, so one codebase can send to a staging project and a production project. +A send can't mix the two forms. Pass `template_id`, or `template_key` with the content. ## Before you start -1. **Verify a sender.** [Set up the email channel](/docs/workflows/configure-channels) with a domain that you own, and make the verified sender your project's default sender: click **Make default** next to the sender in the email channel settings. The first upload uses the default sender. Without one, the upload fails with [`sender_unavailable`](#sender_unavailable). -2. **Create a project secret API key.** Create a [project secret API key](/docs/api/project-secret-api-keys) (it starts with `phs_`) with the `hog_flow:write` scope. Keep it on your server, for example in the `POSTHOG_PROJECT_SECRET_API_KEY` environment variable. Never put it in browser or mobile code. +1. **Verify a sender.** [Set up the email channel](/docs/workflows/configure-channels) with a domain that you own, and make the verified sender your project's default sender: click **Make default** next to the sender in the email channel settings. An email from your code goes out from the default sender unless the send sets `from`. +2. **Create a project secret API key.** Create a [project secret API key](/docs/api/project-secret-api-keys) (it starts with `phs_`) with the `hog_flow:write` scope, which also lets it [read an email's status](#read-an-emails-status). Keep it on your server, for example in the `POSTHOG_PROJECT_SECRET_API_KEY` environment variable. Never put it in browser or mobile code. 3. **Ask for access.** The API must be enabled for your project. See the beta note above. -A project secret API key can only upload templates and send them. It can't read, edit, or delete templates. To call the other template routes, such as [adoption](#adopt-a-template-made-in-posthog), use a [personal API key](/docs/api/personal-api-keys) with `hog_flow:write`. A [change request](#run-with-posthog-code) needs `hog_flow:read` and `task:write` instead. +A project secret API key can only upload templates, send them, and read the status of an email. It can't read, edit, or delete templates. To call the other template routes, such as [adoption](#adopt-a-template-made-in-posthog), use a [personal API key](/docs/api/personal-api-keys) with `hog_flow:write`. A [change request](#run-with-posthog-code) needs `hog_flow:read` and `task:write` instead. -Project secret API keys are in beta and may not be available in your organization yet. Until they are, the upload and send routes also accept a personal API key with `hog_flow:write` and editor access to Workflows. With a personal API key, put the project ID in the URL instead of `@current`. `posthog-node` always calls `@current`, so use it with a project secret API key. +Project secret API keys are in beta and may not be available in your organization yet. Until they are, the upload and send routes also accept a personal API key with `hog_flow:write` and editor access to Workflows. With a personal API key, put the project ID in the URL instead of `@current`. The SDKs always call `@current`, so use them with a project secret API key. -## Write a template +## Set up the client -A template is an HTML file. The `` is the subject line, and `<meta name="preheader">` is the inbox preview text. Both can use Liquid. +Email is a namespace on the PostHog client, `posthog.emails`. Give the client your project secret API key: -```html file=emails/welcome.html -<!doctype html> -<html> - <head> - <meta charset="utf-8" /> - <title>Welcome to Hedgebox, {{ variables.first_name }} - - - -

Hi {{ variables.first_name }},

-

Your account is ready. Log in to get started.

- - -``` - -- **Values come from the send only.** Each send passes its values as `data` (in Node.js) or `message_data` (over HTTP). The template reads them as `{{ variables. }}`. -- **A send never reads person properties.** PostHog attaches the person to the send for attribution, but the template can't read `{{ person.properties.* }}`. Pass every value the email needs in the send. -- **The key names the template.** A key has only letters, digits, hyphens (`-`), and underscores (`_`), with at most 400 characters. In Node.js, the key is the file name without `.html`, so `emails/welcome.html` has the key `welcome`. - -## Send from Node.js - -Use `posthog-node` on your server. Set `secretKey` to your project secret API key: + ```js import { PostHog } from 'posthog-node' @@ -67,183 +47,530 @@ import { PostHog } from 'posthog-node' const posthog = new PostHog('', { host: '', secretKey: process.env.POSTHOG_PROJECT_SECRET_API_KEY, - // Leave this out if the key also has feature_flag:read and you use local evaluation - enableLocalEvaluation: false, }) ``` -`secretKey` also turns on [local evaluation](/docs/feature-flags/local-evaluation) of Feature Flags, which needs the `feature_flag:read` scope. If the key only has `hog_flow:write`, set `enableLocalEvaluation: false`. +```python +import os +from posthog import Posthog + +posthog = Posthog( + '', + host='', + secret_key=os.environ['POSTHOG_PROJECT_SECRET_API_KEY'], +) +``` + + -Reference the template file once, then send it: +| Node.js | Python | Description | +| --- | --- | --- | +| `secretKey` | `secret_key` | A project secret API key with `hog_flow:write`. Without it, a send fails with [`missing_secret_key`](#missing_secret_key). | +| `appHost` | `app_host` | The PostHog app host that receives the send, such as `https://posthog.example.com` for a self-hosted instance. See below. | +| `maxRetries` | `max_retries` | How often the SDK retries a failed send. Defaults to `2`, so a send makes at most 3 attempts. `0` turns retries off. A send can override it. See [retries](#what-the-sdks-retry). | -```js -const welcome = posthog.emails.templateFile(new URL('./emails/welcome.html', import.meta.url)) +**The app host.** Sends go to the PostHog app, not to the ingestion host that events use. When `host` is PostHog Cloud (`us.i.posthog.com`, `us.posthog.com`, `eu.i.posthog.com`, or `eu.posthog.com`), the SDK knows the app host. Any other `host` can be a reverse proxy for events, and the secret key must never go there. So for any other host, set `appHost` (`app_host` in Python). Without it, a send fails with [`missing_app_host`](#missing_app_host) and sends no request. + +In Node.js, `secretKey` also turns on [local evaluation](/docs/feature-flags/local-evaluation) of Feature Flags, which needs the `feature_flag:read` scope. If the key only has `hog_flow:write`, set `enableLocalEvaluation: false`. +In Python, `AsyncPosthog` has the same `emails` namespace, with an awaitable `send`. + +## Send a template made in PostHog + +Pass the template's ID as `template_id`, and the values for its placeholders as `template_data`. Copy the ID from the template's **API** tab in Workflows. The template must be transactional. + + + +```js const { data, error } = await posthog.emails.send({ - template: welcome, to: 'jane@example.com', - data: { first_name: 'Jane', login_url: 'https://hedgebox.example.com/login' }, - distinctId: 'user_42', + distinctId: user.id, + templateId: '01a14631-40b7-0000-4dfd-60cd5a2e12ad', + templateData: { first_name: 'Jane', login_url: 'https://hedgebox.example.com/login' }, }) if (error) { - console.error(error.code, error.message, error.docsUrl) + console.error(error.name, error.code, error.message, error.docsUrl) } ``` -- `templateFile()` takes a `file:` URL or an absolute path to an `.html` file. It throws a `TypeError` for anything else, so call it once when your module loads. Options: `key` (defaults to the file name without `.html`) and `name` (the name in the template library, defaults to the subject). -- `to` is the recipient's email address. -- `data` fills the `{{ variables.* }}` placeholders. -- `distinctId` is optional. It attributes the send and its [engagement events](/docs/workflows/engagement-events) to that person. Without it, PostHog uses the recipient's email address as the distinct ID. -- `send()` never throws on API or network errors. It returns `{ data, error }`. On success, `data` is `{ id, warnings }`. On failure, `error` is `{ code, message, statusCode, docsUrl, field }`. Branch on `error.code`. Each code has a section in [errors](#errors). +```python +from posthog.emails import EmailError + +try: + result = posthog.emails.send( + to='jane@example.com', + distinct_id=user.id, + template_id='01a14631-40b7-0000-4dfd-60cd5a2e12ad', + template_data={'first_name': 'Jane', 'login_url': 'https://hedgebox.example.com/login'}, + ) +except EmailError as error: + print(error.name, error.code, error.message, error.docs_url) +``` + + + +- `to` is one recipient's email address. +- `distinctId` (`distinct_id` in Python) is the recipient's distinct ID in your app. [Always pass it](#always-pass-distinct_id). +- `templateData` (`template_data`) fills the template's `{{ variables.* }}` placeholders. A send never reads person properties, so pass every value the email needs. +- Node.js returns `{ data, error }` and never throws for an API or network error. Python returns the result and raises an `EmailError`. See [results and errors](#results-and-errors-in-the-sdks). + +## Send an email from your code + +Name the email with `template_key`, and pass its `subject` and its `html`, `text`, or both. A key has only letters, digits, hyphens (`-`), and underscores (`_`), with at most 400 characters. Keep the key the same for one kind of email: PostHog groups the email's metrics, logs, and status by its key, in every project. -To send a template that you upload some other way, pass its key as a string: `template: 'welcome'`. A key string sends no content hash, so the send always uses the published content. +### Send rendered content -### How the file stays in sync +Render the email in your code, then send the result. PostHog sends the content as it is. It doesn't fill in placeholders, and it doesn't store the content as a template. + + + +```js +const { data, error } = await posthog.emails.send({ + to: 'jane@example.com', + distinctId: user.id, + templateKey: 'password-reset', + subject: 'Reset your password', + html: `

Hi Jane,

Reset your password. The link works for one hour.

`, + from: 'Hedgebox ', +}) +``` + +```python +result = posthog.emails.send( + to='jane@example.com', + distinct_id=user.id, + template_key='password-reset', + subject='Reset your password', + html=f'

Hi Jane,

Reset your password. The link works for one hour.

', + from_='Hedgebox ', +) +``` + +
+ +When you send `html` without `text`, PostHog derives the plain-text part from the HTML. When the HTML has no text to derive, such as an email that is only an image, the email goes out without a plain-text part. + +### Send a React Email component + +The SDKs have no React helper. Render the component to HTML with [React Email](https://react.email) in your code, then send the HTML as rendered content: + +```jsx +import { render } from '@react-email/render' +import { PasswordReset } from './emails/PasswordReset' + +const { data, error } = await posthog.emails.send({ + to: 'jane@example.com', + distinctId: user.id, + templateKey: 'password-reset', + subject: 'Reset your password', + html: await render(), +}) +``` -On every send with a template file, `posthog-node` reads the file, computes its [content hash](#content-hash), and sends the key and the hash: +Your components stay as they are. PostHog derives the text part from the rendered HTML. + +### Send a local template + +A local template keeps the email in your code, with `{{ variables.* }}` placeholders in the subject and the body. Send it with `template_data`, and PostHog fills in the values: + + + +```js +import { readFileSync } from 'node:fs' + +const welcomeHtml = readFileSync(new URL('./emails/welcome.html', import.meta.url), 'utf8') + +const { data, error } = await posthog.emails.send({ + to: 'jane@example.com', + distinctId: user.id, + templateKey: 'welcome', + subject: 'Welcome to Hedgebox, {{ variables.first_name }}', + html: welcomeHtml, + templateData: { first_name: 'Jane', login_url: 'https://hedgebox.example.com/login' }, +}) +``` + +```python +from pathlib import Path + +welcome_html = (Path(__file__).parent / 'emails' / 'welcome.html').read_text() + +result = posthog.emails.send( + to='jane@example.com', + distinct_id=user.id, + template_key='welcome', + subject='Welcome to Hedgebox, {{ variables.first_name }}', + html=welcome_html, + template_data={'first_name': 'Jane', 'login_url': 'https://hedgebox.example.com/login'}, +) +``` + + + +`template_data` is what makes the send a local template. Without it, the same call sends rendered content, and the placeholders go out as written. + +The template uses [Liquid](https://shopify.github.io/liquid/). It reads only the values of the send, as `{{ variables. }}`. PostHog attaches the person to the send for attribution, but the template can't read `{{ person.properties.* }}`. + +#### How a local template stays in sync + +Your code is the source of truth. On every send, the SDK computes the template's [content hash](#content-hash) and sends the key and the hash: - **PostHog holds this content.** The email goes out. This is one request. -- **PostHog never had this content**, because the file is new or you edited it. PostHog answers [`template_not_found`](#template_not_found) or [`template_outdated`](#template_outdated) and queues nothing. `posthog-node` uploads the file and sends again, once. This is three requests. -- **Newer content replaced this content.** The email goes out with the newer content, and `data.warnings` has a [`template_superseded`](#template_superseded) warning. `posthog-node` doesn't upload. It writes the warning to its logger once per key and content. +- **PostHog doesn't hold this content**, because the key is new or you changed the template. PostHog answers [`template_outdated`](#template_outdated) and queues nothing. The SDK uploads the template under its key and sends again once, with the same [idempotency key](#idempotency). This is three requests. +- **Newer content replaced this content.** The email goes out with the newer content, and the result has a [`template_superseded`](#template_superseded) warning. The SDK doesn't upload. It logs the warning once. + +So you don't need an upload step or a deploy step: the first send of a changed template publishes the change. The uploaded template is [managed by code](#templates-managed-by-code), so nobody can edit it in PostHog. -So you don't need an upload step or a deploy step: a send from an edited file publishes the edit. +The SDK's upload refuses to overwrite a template that people edit in PostHog. When the key names such a template, a send that needs an upload fails with [`template_edited_in_posthog`](#template_edited_in_posthog) and sends nothing. -### Rolling deploys +#### Rolling deploys -During a rolling deploy, your old instances and your new instances run at the same time, each with its own copy of the file. An old instance can't bring back content from any of the last 100 publishes: +During a rolling deploy, your old instances and your new instances run at the same time, each with its own copy of the template. An old instance can't bring back content from any of the last 100 publishes: -- PostHog remembers the last 100 content hashes it published. An older hash counts as never published, so a file older than that uploads again. +- PostHog remembers the last 100 content hashes it published. An older hash counts as never published, so a template older than that uploads again. - When a send carries one of the remembered hashes, PostHog sends the current content and adds the [`template_superseded`](#template_superseded) warning. Nothing is uploaded or reverted. -- The automatic upload after `template_outdated` sets `if_not_superseded`, so it never publishes content that one of the last 100 publishes replaced. See [automatic uploads](#automatic-uploads-and-if_not_superseded). +- The SDK's upload sets `if_not_superseded`, so it never publishes content that one of the last 100 publishes replaced. See [automatic uploads](#automatic-uploads-and-if_not_superseded). Two side effects to know about: -- In development, when you edit a file back to an earlier version, a send doesn't upload it, because PostHog published that content before. The warning tells you to upload it on purpose. -- When two people share one project with different local copies of a file, each sees the other's content as newer. Use one project per person, or upload explicitly. +- In development, when you change a template back to an earlier version, a send doesn't upload it, because PostHog published that content before. The warning tells you to upload it on purpose. +- When two people share one project with different local copies of a template, each sees the other's content as newer. Use one project per person, or upload explicitly. + +To publish content that newer content replaced, such as when you roll back a release, upload it with the [upload route](#upload-a-template-by-key), without `if_not_superseded`. An upload that you make on purpose always publishes. + +### Set the sender + +| Node.js | Python | Description | +| --- | --- | --- | +| `from` | `from_` | The sender, as `security@example.com` or `Hedgebox `, on a domain that the project verified. Without it, rendered content goes out from the project's default sender, and a local template from its template's sender. A sender that no verified sender of the project can send from fails with [`sender_not_allowed`](#sender_not_allowed). Without `from` and without a default sender, the send fails with [`sender_not_verified`](#sender_not_verified). | +| `replyTo` | `reply_to` | One address, or a list of up to 10 addresses, that replies go to. | + +Python names the sender `from_`, because `from` is a Python keyword. `from` and `reply_to` only go with `template_key`. A template made in PostHog uses its own sender. + +### Add copies, headers, tags, and attachments -### Roll back on purpose +An email from your code can carry copies, custom headers, tags, and files. Rendered content and local templates take them both: -To publish content that newer content replaced, such as when you roll back a release, upload the file explicitly: + ```js -const { data, error } = await posthog.emails.uploadTemplate(welcome, { - source: { repository: 'github.com/example/app', ref: process.env.GIT_COMMIT_SHA }, +const { data, error } = await posthog.emails.send({ + to: 'jane@example.com', + distinctId: user.id, + templateKey: 'order-receipt', + subject: 'Your Hedgebox receipt', + html: receiptHtml, + bcc: 'records@example.com', + headers: { 'X-Entity-Ref-ID': 'order-123' }, + tags: [{ name: 'category', value: 'receipt' }], + attachments: [{ filename: 'receipt.pdf', content: receiptPdf, contentType: 'application/pdf' }], }) -// data: { id, key, result: 'created' | 'updated' | 'unchanged', contentHash } ``` -An explicit upload always publishes. `source` is optional and shows in PostHog next to the template. Each part that you leave out comes from [source detection](#where-the-source-details-come-from). +```python +result = posthog.emails.send( + to='jane@example.com', + distinct_id=user.id, + template_key='order-receipt', + subject='Your Hedgebox receipt', + html=receipt_html, + bcc='records@example.com', + headers={'X-Entity-Ref-ID': 'order-123'}, + tags=[{'name': 'category', 'value': 'receipt'}], + attachments=[{'filename': 'receipt.pdf', 'content': receipt_pdf, 'content_type': 'application/pdf'}], +) +``` -### Ship template files with serverless bundles + -`posthog-node` reads the template file at runtime, so the file must be in the deployment. Bundlers only ship files that your code imports, so a serverless function can deploy without its HTML files. A send then fails with [`template_file_unreadable`](#template_file_unreadable). +| Node.js | Python | Description | +| --- | --- | --- | +| `cc` | `cc` | One address, or a list of up to 10, that get a visible copy. Use bare addresses, such as `jane@example.com`, without a display name. | +| `bcc` | `bcc` | One address, or a list of up to 10, that get a hidden copy. The other recipients never see them. | +| `headers` | `headers` | Up to 10 custom headers, such as `{ "X-Entity-Ref-ID": "order-123" }`. | +| `tags` | `tags` | Up to 10 `{ name, value }` pairs. PostHog adds them to the email's [engagement events](#engagement-capture) as `$email_tags`. | +| `attachments` | `attachments` | Up to 10 files, with at most 512 KB in total after decoding. Each file is `{ filename, content, contentType?, contentId? }` (`content_type` and `content_id` in Python). `content` is a `Buffer` or a Base64 string in Node.js, and `bytes` in Python. | -Tell your platform to include the files: +- **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 any `Content-*`, `X-PostHog-*`, or `X-SES-*` header. A header name has no spaces or colons, and a value is printable ASCII on one line. Header names ignore case, so set each name once. +- **Tags.** A tag name and a value use only ASCII letters, numbers, `_`, and `-`. Names that start with `ph_` are PostHog's. Each name can appear once. +- **Attachments.** Mailbox providers block some file types, such as `.exe`, and PostHog refuses them. A file name has no path and no line breaks. To show a file inline, give it a `contentId` (`content_id`), such as `logo`, and reference it in the HTML as ``. Send a link instead of a larger file. +- **Suppressed addresses.** When the `to` address, a `cc`, or a `bcc` address is suppressed, PostHog sends nothing and answers [`recipient_suppressed`](#recipient_suppressed). Every address counts as a recipient for the project's sending limits. -- **Next.js:** add the files to `outputFileTracingIncludes` in `next.config.js`, for example `{ '/api/signup': ['./emails/**/*.html'] }`. -- **Vercel functions:** add `"includeFiles": "emails/**"` to the function in `vercel.json`. -- **Netlify functions:** add `included_files = ["emails/**"]` under `[functions]` in `netlify.toml`. +A template made in PostHog sends with its own settings, so a send by `template_id` takes none of these fields. A field that breaks a rule fails with [`invalid_request`](#invalid_request), and `field` names it, such as `tags__0__name`. -When a bundler moves your code to another directory, `import.meta.url` no longer points next to the file. Build the path from the project root instead, for example `path.join(process.cwd(), 'emails/welcome.html')`. +## Always pass `distinct_id` -### Edge runtimes +Pass `distinctId` (`distinct_id` in Python) on every send, with the ID your app already uses for the user in PostHog. PostHog captures the email's [engagement events](/docs/workflows/engagement-events) on that person, next to their product events. -Edge runtimes have no file system, so the edge entry of `posthog-node` only takes key strings. `templateFile()` throws there. Upload the file from a build step or a Node.js script with `uploadTemplate()`, or over HTTP with the [upload route](#upload-a-template-by-key), then send by key. +Without it, PostHog uses the recipient's email address as the distinct ID. The events then land on a separate person keyed by the address, not on your app's user. -## Upload a template by key +The SDKs never take the recipient from the current request context. An invite that an admin sends lands on the invited person, not on the admin's timeline. -`PUT /api/projects/:project_id/messaging_templates/by_key/:key/` +## What a successful send means -Use this route from any language, for example in a deploy step. With a project secret API key, use `@current` as the project ID. A personal API key or a session can call it too, with editor access to Workflows. +A successful send answers `202`. It means PostHog stored the send in its queue for delivery. It doesn't mean that the email provider accepted the email, or that the email was delivered. -```bash -curl -X PUT "/api/projects/@current/messaging_templates/by_key/welcome/" \ - -H "Authorization: Bearer $POSTHOG_PROJECT_SECRET_API_KEY" \ - -H "Content-Type: application/json" \ - -d '{ - "name": "Welcome", - "subject": "Welcome to Hedgebox, {{ variables.first_name }}", - "html": "

Hi {{ variables.first_name }}

", - "source": { "repository": "github.com/example/app", "path": "emails/welcome.html", "ref": "3f2c1ab" } - }' +PostHog runs the checks that would stop the email before it answers. A send to a suppressed address, from a sender that isn't verified, or from a paused template fails with an error that says why and how to fix it, instead of a `202` and a skipped email. Each check has a code in [errors](#errors). + +The result carries the ID of the queued send, and any warnings: + +- **Node.js:** `data` is `{ id, warnings }`. Each warning is `{ code, message, fix }`. +- **Python:** the result has `id`, `replayed`, and `warnings`. Each warning has `code`, `message`, and `fix`. + +The SDKs log each warning once through the client's logger. They capture no event for a send: the send and its engagement events come from PostHog, so nothing is counted twice. + +## Idempotency + +Every send carries an idempotency key, so a retry doesn't send a second email. + +- Pass your own key as `idempotencyKey` (`idempotency_key` in Python). When you pass none, the SDK makes a random UUID for the send, and uses it for each of its retries. +- A key has 1 to 255 visible ASCII characters, with no spaces. A wrong key fails with [`invalid_idempotency_key`](#invalid_idempotency_key). +- PostHog keeps a key for 24 hours. + +What PostHog does with a key that it saw before: + +| The second request | Result | +| --- | --- | +| is the same as the first, and the first was queued | The first `202`, with the same message ID. Nothing new is sent, and PostHog runs no checks again. Python's result has `replayed` set. | +| is the same as the first, and the first is still in progress | `409` with the name `concurrent_idempotent_requests`. See [`idempotency_key_in_flight`](#idempotency_key_in_flight). The SDKs retry it. | +| differs from the first, or goes to the other send route | `422` [`idempotency_key_reused`](#idempotency_key_reused), with the name `invalid_idempotent_request`. Nothing is sent. | + +A request counts as the same when it names the same template (`template_id` or `template_key`), recipient, distinct ID, `template_data`, and content, with the same sender, copies, headers, tags, and attachments. A send that names no `from` stays the same when the project's default sender changes. + +Template sends and code sends share one set of keys, so a key names one email across both send routes. + +A replay answers before the checks run, so a retry still gets its `202` when the email's workflow was paused after the first send, or when the default sender changed. A retry can still fail when the template is deleted (`404`), when the workflow of a template stored in PostHog is deleted (`400` [`transactional_email_paused`](#transactional_email_paused)), when the workflow of rendered content whose key names no stored template is deleted (`422` [`idempotency_key_reused`](#idempotency_key_reused), because PostHog makes a new workflow for the key), when a local template's content changed (`409` [`template_outdated`](#template_outdated)), or when the project no longer has a default sender (`400` [`sender_not_verified`](#sender_not_verified)). + +### What the SDKs retry + +The SDKs retry a send that failed with a network error, the in-flight `409`, `429` [`throttled`](#throttled), or `503`, each time with the same idempotency key. They wait 1 second, then 2 seconds, with some jitter, or the time in the `Retry-After` header when that is longer, up to 30 seconds. + +They never retry [`quota_limited`](#quota_limited): waiting doesn't fix a monthly quota. They retry no other error, because the same request gets the same answer. + +### When a job runner retries + +When a job runner, such as a queue worker, already retries the job that sends, turn the SDK's retries off and derive the key from the email's purpose: + + + +```js +const { error } = await posthog.emails.send({ + to: user.email, + distinctId: user.id, + templateKey: 'welcome', + subject: 'Welcome to Hedgebox, {{ variables.first_name }}', + html: welcomeHtml, + templateData: { first_name: user.firstName }, + idempotencyKey: `welcome:${user.id}`, + maxRetries: 0, +}) + +if (error) { + // Fail the job, so the job runner retries it with the same key + throw new Error(`${error.code}: ${error.message}`) +} ``` -| Field | Type | Description | +```python +posthog.emails.send( + to=user.email, + distinct_id=user.id, + template_key='welcome', + subject='Welcome to Hedgebox, {{ variables.first_name }}', + html=welcome_html, + template_data={'first_name': user.first_name}, + idempotency_key=f'welcome:{user.id}', + max_retries=0, +) +``` + + + +A UUID that the SDK makes is new for each run of the job, so a job retry would send a second email. A key like `welcome:user_123` stays the same in every run. + +Node.js returns the error instead of throwing it, so throw it yourself to fail the job. In Python, `send` raises an `EmailError`. Retry only what the SDKs would retry, listed in [what the SDKs retry](#what-the-sdks-retry). Any other error gets the same answer on every run, so stop the job and fix the call. + +### What a key promises + +A key promises one send per key when you retry. It doesn't promise that an email is delivered exactly once. In rare cases, a failure inside PostHog between accepting a send and queuing it can queue the email twice. + +## Engagement capture + +PostHog captures the engagement events of an email (sent, delivered, opened, clicked, bounced) as analytics events of the recipient. They count as analytics events in your project. + +- For an email from your code, capture is on by default. +- For a template made in PostHog, the project's setting applies. +- To turn it off for one send, pass `captureEngagement: false` (`capture_engagement=False` in Python). + +## Results and errors in the SDKs + +Both SDKs report the same fields for an error: + +| Node.js | Python | Description | | --- | --- | --- | -| `name` | string, required | Name in the template library. At most 400 characters. | -| `subject` | string, required | Subject line. Supports Liquid. | -| `html` | string, required | HTML body, stored as you send it. Supports Liquid. | -| `text` | string | Plain-text body. Leave it out to remove the stored one. | -| `preheader` | string | Inbox preview text. Leave it out to remove the stored one. | -| `source` | object | `{ repository, path, ref }`, each a string of at most 400 characters. Shown in PostHog next to the template. Leave it out, or leave out a part, to keep what is stored. | -| `if_not_superseded` | boolean | Refuse to publish content that newer content already replaced. See [automatic uploads](#automatic-uploads-and-if_not_superseded). | +| `name` | `name` | The error's name, from [Resend's error names](https://resend.com/docs/api-reference/errors), such as `rate_limit_exceeded`. Code that you move from Resend and that branches on `name` keeps working. | +| `code` | `code` | PostHog's stable code to branch on. Each code has a section in [errors](#errors). | +| `message` | `message` | What happened. | +| `why` | `why` | Why PostHog refused the send, when PostHog says. | +| `fix` | `fix` | What to change before you retry, when PostHog says. | +| `statusCode` | `status_code` | The HTTP status, when PostHog answered. | +| `docsUrl` | `docs_url` | The link to the code's section on this page, when PostHog sends one. | +| `field` | `field` | The request field that the error is about, when there is one. | + +Node.js returns `{ data, error }` and never throws for an API or network error, like Resend's Node.js SDK. Python raises an `EmailError` that carries these fields, like Resend's Python SDK. + +The `name` follows the status and the code: + +| Status | `code` | `name` | Retried | +| --- | --- | --- | --- | +| `400` | A field, such as [`invalid_request`](#invalid_request), or a send check: [`invalid_recipient`](#invalid_recipient), [`sender_not_verified`](#sender_not_verified), [`sender_not_allowed`](#sender_not_allowed), [`sending_suspended`](#sending_suspended), [`template_paused`](#template_paused), [`transactional_email_paused`](#transactional_email_paused), or [`recipient_suppressed`](#recipient_suppressed). The SDK's local checks, such as [`invalid_form`](#invalid_form), have this name too | `validation_error` | No | +| `401` | [`authentication_failed`](#authentication_failed), and the SDK's [`missing_secret_key`](#missing_secret_key) | `missing_api_key` | No | +| `403` | [`permission_denied`](#permission_denied) | `invalid_permission` | No | +| `404` | [`template_not_found`](#template_not_found), [`not_found`](#not_found) | `not_found` | No | +| `409` | [`template_outdated`](#template_outdated) | `validation_error` | The SDK uploads, then sends once more | +| `409` | [`idempotency_key_in_flight`](#idempotency_key_in_flight) | `concurrent_idempotent_requests` | Yes | +| `409` | [`template_edited_in_posthog`](#template_edited_in_posthog) | `validation_error` | No | +| `422` | [`idempotency_key_reused`](#idempotency_key_reused) | `invalid_idempotent_request` | No | +| `429` | [`throttled`](#throttled) | `rate_limit_exceeded` | Yes | +| `429` | [`quota_limited`](#quota_limited) | `monthly_quota_exceeded` | No | +| `503` | [`delivery_unavailable`](#delivery_unavailable), and the SDK's [`network_error`](#network_error) | `service_unavailable` | Yes | +| `5xx` | Anything else, and the SDK's [`api_error`](#api_error) and [`unexpected_error`](#unexpected_error) | `application_error` | No | + +## Read an email's status + +A `202` means PostHog queued the email. To see where it is later, read its status by the ID that the send returned. The key needs the `hog_flow:read` scope, and `hog_flow:write` covers it. + + -PostHog stores `subject`, `preheader`, `html`, and `text` byte for byte. It doesn't trim whitespace, so the hash you compute matches the hash PostHog stores. +```js +const { data, error } = await posthog.emails.get(sent.id) -The response: +if (data?.status === 'bounced') { + console.log(`Bounced: ${data.reason}`) +} +``` + +```bash +curl "/api/projects/@current/messaging/emails/01a146ff-a14e-0000-81c0-f2ccc2444f5f/" \ + -H "Authorization: Bearer $POSTHOG_PROJECT_SECRET_API_KEY" +``` + + + +The response is `200`: ```json { - "id": "01a14631-40b7-0000-4dfd-60cd5a2e12ad", - "key": "welcome", - "result": "created", - "content_hash": "v1:67bd514eca757ac54081c4eda9ca9cc9d3704594f0d83843a76a77364a2c6a12", - "managed_by": "code", - "source": { "repository": "github.com/example/app", "path": "emails/welcome.html", "ref": "3f2c1ab" }, - "transactional_hog_flow_id": "01a14631-40c1-0000-d930-cf5400ef453a" + "id": "01a146ff-a14e-0000-81c0-f2ccc2444f5f", + "status": "delivered", + "created_at": "2026-10-23T09:12:04Z", + "status_at": "2026-10-23T09:12:07Z" } ``` -| `result` | Status | What happened | -| --- | --- | --- | -| `created` | `201` | No template had this key. PostHog created one, marked it as managed by code, and made it transactional with the project's default sender. | -| `updated` | `200` | PostHog published the uploaded content. The next send uses it. | -| `unchanged` | `200` | The template already had this name and content. PostHog wrote nothing, except source parts that changed. | -| `superseded` | `200` | Only with `if_not_superseded`. Newer content replaced this content, so PostHog wrote nothing. `content_hash` is the current hash. | +- `status` moves forward in this order, and never back: `queued`, `waiting`, `unknown`, `skipped`, `failed`, `sent`, `delivered`, `bounced`, `complained`. A late `sent` never replaces `delivered`. It isn't Resend's `last_event`. +- `reason` is optional. When PostHog knows it, it says why an email is `skipped`, `failed`, `bounced`, or `complained`, such as `recipient_suppressed`, `provider_error`, or `hard`. +- `created_at` is when PostHog accepted the send, and `status_at` is when the email reached its status. In Node.js, they are `createdAt` and `statusAt`. -An upload never turns a paused template back on. It keeps the template's sender, unless the template's workflow was deleted: then the upload creates a new workflow with the project's default sender. When two first uploads of one key run at the same time, both succeed and one template exists. +PostHog keeps a status for 30 days. A new email can take a few seconds to show up, and until then the read answers `404` [`not_found`](#not_found). The read never retries, so read again later for a newer status. It shares the [rate limits](#rate-limits) of the send. posthog-python has no read yet, so call the route from Python. -### Automatic uploads and `if_not_superseded` +### Find the emails sent to an address + +`GET /api/projects/@current/messaging/emails/?to=jane@example.com` + +The same read, by recipient: the statuses of the recent emails sent to one address, newest first, in pages. Each item has the fields of the read by ID. The key needs `hog_flow:read`, and the read shares the send's rate limits. -An automatic upload, like the one `posthog-node` makes after `template_outdated`, should set `"if_not_superseded": true`. Between the `409` and the upload, another instance can publish the same content and a newer deploy can replace it. With the option, the upload answers `superseded` and writes nothing, so the old content doesn't come back. Send again, and the email goes out with the current content and a [`template_superseded`](#template_superseded) warning. +```bash +curl -G "/api/projects/@current/messaging/emails/" \ + --data-urlencode "to=jane@example.com" \ + -H "Authorization: Bearer $POSTHOG_PROJECT_SECRET_API_KEY" +``` + +## Send over HTTP -Leave the option out for an upload that you make on purpose, such as a [rollback](#roll-back-on-purpose). +The SDKs call these routes. To send from another language, call them yourself. -## Send an email +### Send an email `POST /api/projects/:project_id/messaging/send/email/` -The send route names the template in the body, by key or by ID: +With a project secret API key, use `@current` as the project ID. A template made in PostHog goes by its ID in `template`: ```bash curl -X POST "/api/projects/@current/messaging/send/email/" \ -H "Authorization: Bearer $POSTHOG_PROJECT_SECRET_API_KEY" \ -H "Content-Type: application/json" \ + -H "Idempotency-Key: welcome:user_42" \ -d '{ - "template": { "key": "welcome" }, + "template": { "id": "01a14631-40b7-0000-4dfd-60cd5a2e12ad" }, "to": "jane@example.com", "identifiers": { "distinct_id": "user_42" }, "message_data": { "first_name": "Jane", "login_url": "https://hedgebox.example.com/login" } }' ``` -| Field | Type | Description | +Rendered content goes by `template_key`, with the content in the body: + +```bash +curl -X POST "/api/projects/@current/messaging/send/email/" \ + -H "Authorization: Bearer $POSTHOG_PROJECT_SECRET_API_KEY" \ + -H "Content-Type: application/json" \ + -H "Idempotency-Key: password-reset:user_42:7f3c" \ + -d '{ + "template_key": "password-reset", + "to": "jane@example.com", + "identifiers": { "distinct_id": "user_42" }, + "subject": "Reset your password", + "html": "

Hi Jane,

Reset your password.

", + "from": "Hedgebox " + }' +``` + +A local template goes by its key and its [content hash](#content-hash) in `template`, with `message_data`. When PostHog answers [`template_outdated`](#template_outdated), [upload the template](#upload-a-template-by-key), then send again with the same `Idempotency-Key`. The hash must describe the content that you upload: the upload response returns it as `content_hash`. This example sends the template of the [upload example](#upload-a-template-by-key): + +```bash +curl -X POST "/api/projects/@current/messaging/send/email/" \ + -H "Authorization: Bearer $POSTHOG_PROJECT_SECRET_API_KEY" \ + -H "Content-Type: application/json" \ + -H "Idempotency-Key: welcome:user_42" \ + -d '{ + "template": { "key": "welcome", "hash": "v1:67bd514eca757ac54081c4eda9ca9cc9d3704594f0d83843a76a77364a2c6a12" }, + "to": "jane@example.com", + "identifiers": { "distinct_id": "user_42" }, + "message_data": { "first_name": "Jane" } + }' +``` + +| SDK parameter | Request | Description | | --- | --- | --- | -| `template` | object, required | Exactly one of `{ "key": "welcome" }`, `{ "key": "welcome", "hash": "v1:..." }`, or `{ "id": "