From 0c19529faa37f5669a635482258daad0ecef8308 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 22 Oct 2026 03:41:02 +0000 Subject: [PATCH 1/8] docs(workflows): document both send forms, idempotency and the send checks The page now follows the flat send contract of PostHog/posthog#120921 for posthog-node and posthog-python: the template form by template_id, the code form by template_key with rendered content, React Email and local templates, client setup with secret_key, app_host and max_retries, what a 202 means, idempotency and the SDK retries, engagement capture, and the error names. New anchored sections cover the send checks, the idempotency codes and missing_app_host. The file helpers of the earlier posthog-node draft are gone. Co-Authored-By: Claude Opus 5.5 --- .../workflows/transactional-email-api.mdx | 747 +++++++++++++----- 1 file changed, 546 insertions(+), 201 deletions(-) diff --git a/contents/docs/workflows/transactional-email-api.mdx b/contents/docs/workflows/transactional-email-api.mdx index 1f8fcfd52c4f..eb10fb50fde7 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). +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. 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. -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. - -## Write a template - -A template is an HTML file. The `` is the subject line, and `<meta name="preheader">` is the inbox preview text. Both can use Liquid. - -```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.

- - -``` +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. -- **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`. +## Set up the client -## Send from Node.js +Email is a namespace on the PostHog client, `posthog.emails`. Give the client your project secret API key: -Use `posthog-node` on your server. Set `secretKey` to your project secret API key: + ```js import { PostHog } from 'posthog-node' @@ -67,183 +47,411 @@ 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 -Reference the template file once, then send it: +posthog = Posthog( + '', + host='', + secret_key=os.environ['POSTHOG_PROJECT_SECRET_API_KEY'], +) +``` -```js -const welcome = posthog.emails.templateFile(new URL('./emails/welcome.html', import.meta.url)) + + +| 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). | + +**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 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. + -### How the file stays in sync +- `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). -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: +## Send an email from your code -- **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. +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. -So you don't need an upload step or a deploy step: a send from an edited file publishes the edit. +### Send rendered content -### Rolling deploys +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. -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: + -- PostHog remembers the last 100 content hashes it published. An older hash counts as never published, so a file 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). +```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 ', +}) +``` -Two side effects to know about: +```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.

', +) +``` + +
+ +When you send `html` without `text`, PostHog derives the plain-text part from the HTML, so every email has a text version. + +### 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(), +}) +``` + +Your components stay as they are. PostHog derives the text part from the rendered HTML. -- 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. +### Send a local template -### Roll back on purpose +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: -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 }, +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' }, }) -// 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 +from pathlib import Path -### Ship template files with serverless bundles +welcome_html = (Path(__file__).parent / 'emails' / 'welcome.html').read_text() -`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). +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'}, +) +``` -Tell your platform to include the files: + -- **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`. +`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. -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')`. +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.* }}`. -### Edge runtimes +#### How a local template stays in sync -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. +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: -## Upload a template by key +- **PostHog holds this content.** The email goes out. This is one request. +- **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. -`PUT /api/projects/:project_id/messaging_templates/by_key/:key/` +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. -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. +The SDK's upload refuses to overwrite a template that people edit in PostHog. When the key names such a template, the send fails with [`template_edited_in_posthog`](#template_edited_in_posthog) and sends nothing. -```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" } - }' -``` +#### Rolling deploys -| Field | Type | Description | +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 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 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 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 | | --- | --- | --- | -| `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). | +| `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 the project can't use 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. | -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. +`from` and `reply_to` only go with `template_key`. A template made in PostHog uses its own sender. -The response: +## Always pass `distinct_id` -```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" -} +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. + +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. + +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. + +## What a successful send means + +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. + +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` [`invalid_idempotent_request`](#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. + +### 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 +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, +}) ``` -| `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. | +```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, +) +``` -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. + -### Automatic uploads and `if_not_superseded` +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. + +### What a key promises -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. +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. -Leave the option out for an upload that you make on purpose, such as a [rollback](#roll-back-on-purpose). +## Engagement capture -## Send an email +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` | `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 or a send check, such as [`sender_not_verified`](#sender_not_verified) | `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` | [`invalid_idempotent_request`](#invalid_idempotent_request) | `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) | `service_unavailable` | Yes | +| `5xx` | Anything else, and the SDK's [`api_error`](#api_error) and [`unexpected_error`](#unexpected_error) | `application_error` | No | + +## Send over HTTP + +The SDKs call these routes. To send from another language, call them yourself. + +### 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`: + +```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:f74cd913f3aa2e5667a588f2865634e56693df77793ca594d18e9b5f89e3d2e6" }, + "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": "