diff --git a/contents/docs/workflows/transactional-email-api.mdx b/contents/docs/workflows/transactional-email-api.mdx
new file mode 100644
index 000000000000..1f8fcfd52c4f
--- /dev/null
+++ b/contents/docs/workflows/transactional-email-api.mdx
@@ -0,0 +1,748 @@
+---
+title: Transactional email API
+sidebar: Docs
+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 API is in beta, and PostHog enables it per project. To use it, [ask us in-app](https://app.posthog.com/#panel=support%3Afeedback%3A%3Alow%3Atrue) to enable it for your project. Until then, the upload route, the send routes, and the change request route answer `404` with the code [`not_found`](#not_found). Routes, fields, and messages can change while the API is in beta.
+
+
+
+## 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.
+
+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.
+
+The same key works in every project, so one codebase can send to a staging project and a production project.
+
+## 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.
+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 `` is the inbox preview text. Both can use Liquid.
+
+```html file=emails/welcome.html
+
+
+
+
+ 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'
+
+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`.
+
+Reference the template file once, then send it:
+
+```js
+const welcome = posthog.emails.templateFile(new URL('./emails/welcome.html', import.meta.url))
+
+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',
+})
+
+if (error) {
+ console.error(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).
+
+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
+
+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:
+
+- **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.
+
+So you don't need an upload step or a deploy step: a send from an edited file publishes the edit.
+
+### 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:
+
+- 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).
+
+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.
+
+### Roll back on purpose
+
+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 },
+})
+// 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).
+
+### 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).
+
+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`.
+
+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')`.
+
+### Edge runtimes
+
+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.
+
+## Upload a template by key
+
+`PUT /api/projects/:project_id/messaging_templates/by_key/:key/`
+
+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.
+
+```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" }
+ }'
+```
+
+| Field | Type | 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). |
+
+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.
+
+The response:
+
+```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"
+}
+```
+
+| `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. |
+
+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`
+
+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.
+
+Leave the option out for an upload that you make on purpose, such as a [rollback](#roll-back-on-purpose).
+
+## 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:
+
+```bash
+curl -X POST "/api/projects/@current/messaging/send/email/" \
+ -H "Authorization: Bearer $POSTHOG_PROJECT_SECRET_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "template": { "key": "welcome" },
+ "to": "jane@example.com",
+ "identifiers": { "distinct_id": "user_42" },
+ "message_data": { "first_name": "Jane", "login_url": "https://hedgebox.example.com/login" }
+ }'
+```
+
+| Field | Type | Description |
+| --- | --- | --- |
+| `template` | object, required | Exactly one of `{ "key": "welcome" }`, `{ "key": "welcome", "hash": "v1:..." }`, or `{ "id": "" }`. `hash` only goes with `key`. |
+| `to` | string, required | The recipient's email address. |
+| `identifiers.distinct_id` | string | The distinct ID of the recipient's person. Defaults to the recipient's email address. |
+| `message_data` | object | Values for the template, read as `{{ variables.* }}`. |
+
+The response is `202`:
+
+```json
+{ "message_id": "01a146ff-a14e-0000-81c0-f2ccc2444f5f", "warnings": [] }
+```
+
+`message_id` identifies the queued send. `warnings` is empty, or holds a [`template_superseded`](#template_superseded) warning as `{ code, detail, fix }`.
+
+Use `id` for a template that you made in PostHog and that has no key. Copy the template's ID from its **API** tab in Workflows.
+
+### Send with a content hash
+
+Add `hash` next to `key` to check that PostHog holds the content that you hold:
+
+| The hash is | Result |
+| --- | --- |
+| absent | PostHog sends the published content. |
+| the current hash | PostHog sends the published content. |
+| a hash that newer content replaced | PostHog sends the current content, with a [`template_superseded`](#template_superseded) warning. |
+| a hash PostHog never published | `409` [`template_outdated`](#template_outdated). PostHog queues nothing. Upload the template, then send again. |
+
+### Content hash
+
+The content hash is the SHA-256 of the template's subject, preheader, HTML, and text, written as `v1:` and 64 lowercase hex characters. To compute it in another language:
+
+1. Use the empty string for a field that is absent.
+2. Replace `\r\n` and lone `\r` with `\n`. Change nothing else: no trimming, no Unicode normalization.
+3. Start with the UTF-8 bytes of `posthog-email-template-v1\n`.
+4. For each field, in the order subject, preheader, HTML, text, add its UTF-8 byte length in decimal, then `:`, then its UTF-8 bytes, then `\n`.
+5. Hash the bytes with SHA-256.
+
+Test it against this case: the subject `Welcome, {{ variables.first_name }}`, the HTML `Hi {{ variables.first_name }}
`, no preheader, and no text hash to `v1:f74cd913f3aa2e5667a588f2865634e56693df77793ca594d18e9b5f89e3d2e6`.
+
+The upload response carries the hash too, as `content_hash`.
+
+### Send by template ID in the path
+
+`POST /api/projects/:project_id/messaging_templates/:id/send/`
+
+This older route names the template by its ID in the path. Its body is the send body without `template`, and it answers `202 { "message_id": ... }`. The template must be transactional first. It takes no content hash and returns no warnings. Use the [send route](#send-an-email) for new code.
+
+### Sends are not idempotent
+
+PostHog doesn't deduplicate sends. Two identical calls send two emails. `posthog-node` sends again only after `template_not_found` or `template_outdated`, which queue nothing. It never retries after a timeout or a server error, because the first email may already be queued. If you retry on your side, accept that a retry can send a second email.
+
+### Rate limits
+
+The upload route and both send routes share one rate limit per API key, and one across all project secret API keys of a project. When you go over it, PostHog answers `429` with the code [`throttled`](#throttled).
+
+## Templates managed by code
+
+A template that an upload created, or that an upload claimed, is managed by code. In the Workflows template library:
+
+- It shows a **Managed by code** tag.
+- It shows its key, and the file it came from: the path, the repository, and the commit or branch of the last upload. Each part links to the file when PostHog knows the host.
+- It opens as a read-only **Preview** of the subject, preheader, HTML, and text. You can't edit, save, or delete it there, because the next upload would overwrite your changes. To change it, change its file. See [change a code-managed template](#change-a-code-managed-template).
+- **Duplicate** makes an editable copy, with no key.
+
+The same rule applies over the API and the [PostHog MCP](/docs/workflows/surfaces/mcp): an update, a design edit, or a delete answers `403` [`immutable`](#immutable). Uploads and sends still work.
+
+To edit the template in PostHog instead, stop managing it in code. On the template page, open the menu and click **Stop managing in code**. Over the API, send a `PATCH` whose only field is `managed_by`, with a personal API key:
+
+```bash
+curl -X PATCH "/api/projects/:project_id/messaging_templates/:id/" \
+ -H "Authorization: Bearer $POSTHOG_PERSONAL_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{ "managed_by": "gui" }'
+```
+
+The template keeps its key. The next upload under that key manages it in code again.
+
+The lock covers the template only. Its linked workflow stays editable.
+
+## Change a code-managed template
+
+A code-managed template changes in its file, in your repository. PostHog doesn't edit the file. The template page gives you a prompt for your coding agent, and can ask PostHog Code to open a pull request.
+
+### Edit with your agent
+
+1. Open the template in the Workflows template library, and click **Edit with your agent**.
+2. Under **Describe the change**, complete the sentence. It starts with the file, for example ``In `emails/welcome.html`, change``. When PostHog doesn't know the file's path, it starts with ``In the HTML file of the `welcome` email template, change``.
+3. Click **Copy prompt**. It stays disabled with "Describe the change first" until you add to the sentence. To read the whole prompt first, open **Show full prompt**.
+4. Paste the prompt into your coding agent, in a checkout of the template's repository. The agent changes the file.
+
+The prompt is your sentence, then a block that PostHog builds from the template. It leaves out each detail that it doesn't know:
+
+```text
+In `emails/welcome.html`, change the button color to green.
+
+About this file:
+- It is the PostHog email template with the key `welcome`, in the repository `github.com/example/app`, last uploaded from commit `3f2c1ab`.
+- The file is the whole email. The subject is the first ``. The inbox preview text is the `content` of ``. Everything in the file is the HTML that PostHog sends.
+- Personalize only with `{{ variables. }}`. The values come from the `data` the app passes to `posthog.emails.send`. This file uses `{{ variables.first_name }}` and `{{ variables.login_url }}`.
+- Do not upload the file by hand. If the app sends it with `posthog.emails.templateFile()`, the next send uploads the changed file. If the app sends it by the key `welcome`, upload the file again after the change (PUT /api/projects/@current/messaging_templates/by_key/welcome/ or the PostHog MCP upload tool).
+- The template in PostHog: /project/:project_id/workflows/library/templates/:id
+```
+
+The path, repository, and commit come from the template's [source details](#where-the-source-details-come-from). Without a path, the first point of the block tells the agent to find the file that the app loads with `posthog.emails.templateFile()` for the key.
+
+An agent that reads the template over the API or the [PostHog MCP](/docs/workflows/surfaces/mcp) gets the same text: the template's `change_prompt_starter` is the start of the sentence, `change_prompt_context` is the block, and `change_prompt_variables` lists the variables the block names. All three are `null` for a template that isn't managed by code.
+
+### Run with PostHog Code
+
+When your organization can use PostHog Code, the popover also shows **Run with PostHog Code**. It sends your sentence to PostHog Code, which starts a task on the template's GitHub repository. The task changes only the template's file and opens a pull request. It never uploads the template. A toast and the notice on the page link to the task.
+
+The button is disabled when:
+
+- "You don't have sufficient permissions for this workflow.", followed by your access level and the required one: starting a task needs editor access to Workflows. Ask a project admin for it.
+- "PostHog doesn't know this template's GitHub repository": the template's source has no repository. Upload the template with its [repository](#where-the-source-details-come-from), such as `github.com/example/app`. A repository that isn't on GitHub answers [`template_repository_unsupported`](#template_repository_unsupported) when you click.
+- "Checking the GitHub connection": PostHog is still loading the project's integrations.
+- "Connect GitHub to run PostHog Code": the project has no GitHub integration. Connect GitHub in the project's integrations, and give the PostHog app access to the repository.
+- "Shorten the change to 2,000 characters": the sentence is longer than the route takes.
+- "Describe the change first": the sentence has nothing after the start.
+
+When PostHog Code can't start, the popover shows the error and its fix, and keeps your sentence. If you closed the popover first, a toast shows them. Each click that succeeds starts another task.
+
+You can start the same task over the API, with a personal API key or OAuth that has the `hog_flow:read` and `task:write` scopes and editor access to Workflows. `hog_flow:write` covers `hog_flow:read`. A project secret API key can't start a task.
+
+```bash
+curl -X POST "/api/projects/:project_id/messaging_templates/:id/request_change/" \
+ -H "Authorization: Bearer $POSTHOG_PERSONAL_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{ "instruction": "Make the button green." }'
+```
+
+- `instruction` is required, with at most 2,000 characters, such as `Make the button green.` When it doesn't start with the template's `change_prompt_starter`, PostHog puts the starter and `what this request asks for:` in front of it, so the task knows the file.
+- The response is `201` with `task_id`, `task_url`, and `repository` (`owner/name`).
+- The PostHog MCP tool `workflows-request-email-template-change` calls this route.
+- The errors are [`instruction_missing`](#instruction_missing), [`template_not_managed_by_code`](#template_not_managed_by_code), [`template_repository_missing`](#template_repository_missing), [`template_repository_unsupported`](#template_repository_unsupported), [`github_repository_not_connected`](#github_repository_not_connected), [`posthog_code_not_available`](#posthog_code_not_available), and [`posthog_code_run_failed`](#posthog_code_run_failed).
+
+Use it only when you can't change the file yourself. A coding agent whose workspace holds the repository, or that runs outside PostHog Code and can check it out, edits the file that `change_prompt_context` names, as it describes. Inside PostHog Code, such as PostHog AI or the Slack bot, the route refuses the call with [`posthog_code_not_available`](#posthog_code_not_available).
+
+### Ask PostHog AI or the Slack bot
+
+[PostHog AI](/docs/posthog-ai) and the [PostHog bot in Slack](/docs/posthog-ai/surfaces/slack) run inside PostHog Code, and a running PostHog Code task can't start another one. So when you ask either of them to change a code-managed template, it can't open a pull request. It gives you the link to the template instead. Open it, click **Edit with your agent**, describe the change, and click **Run with PostHog Code** to open the pull request.
+
+A coding agent that has a checkout of the repository, such as one connected to the PostHog MCP in your editor, changes the file itself.
+
+### Copy the HTML file
+
+A template that was uploaded without a file, for example by an agent over the MCP, has no file in your repository yet. To make one, click the arrow next to **Edit with your agent**, then **Copy HTML file**. Save the result as an `.html` file named after the template's key, such as `emails/welcome.html` for the key `welcome`, and send it with [`templateFile()`](#send-from-nodejs). For another file name, pass the `key` option to `templateFile()`. Otherwise, the first send uploads a new template under another key.
+
+- The file is in the format that `posthog.emails.templateFile()` reads. The subject goes into the first ``, and the preheader into ``.
+- A template that came from a file copies back as that file, byte for byte.
+- The file has no plain-text part. When the template has one, the toast adds: "This file has no plain-text part. The next upload from this file removes the plain-text version."
+
+### When the change goes live
+
+Copying a prompt or a file, or starting a PostHog Code task, changes nothing in PostHog. PostHog keeps sending the current version of the template until your app sends the changed file with `templateFile()`, or you upload it. For a pull request, that is after you merge it and deploy your app. When your app sends the template by its key, upload the file after the merge.
+
+## Where the source details come from
+
+An upload's `source` tells PostHog where the template's file is: the `repository`, the `path` of the file in it, and the `ref` it was uploaded from. PostHog shows them on the template and puts them in the [agent prompt](#edit-with-your-agent). [Run with PostHog Code](#run-with-posthog-code) needs the repository.
+
+`posthog-node` fills each part on its own. The repository and the ref come from the first of these that has a value:
+
+1. The `source` option of [`uploadTemplate()`](#roll-back-on-purpose). It wins for the path too. The automatic upload after a send has no option, so it starts at step 2.
+2. The variables of a CI or hosting platform.
+3. The git checkout nearest above the template file.
+
+| Platform | Repository | Ref |
+| --- | --- | --- |
+| GitHub Actions | `GITHUB_SERVER_URL` (the host) and `GITHUB_REPOSITORY` | `GITHUB_SHA` |
+| GitLab CI | `CI_SERVER_HOST` and `CI_PROJECT_PATH` | `CI_COMMIT_SHA` |
+| Vercel | `VERCEL_GIT_PROVIDER` (`github`, `gitlab`, or `bitbucket`), `VERCEL_GIT_REPO_OWNER`, and `VERCEL_GIT_REPO_SLUG` | `VERCEL_GIT_COMMIT_SHA` |
+| Railway | `RAILWAY_GIT_REPO_OWNER` and `RAILWAY_GIT_REPO_NAME`, on `github.com` | `RAILWAY_GIT_COMMIT_SHA` |
+
+- When the variables of more than one platform are set, the first row in the table wins.
+- A ref variable counts only when it holds a full commit hash (40 or 64 hex characters).
+- **Git checkout.** The repository is the `origin` remote in the checkout's `.git/config`. The ref is the commit that `HEAD` points to. `posthog-node` reads these as files and never runs `git`. It follows a worktree's `.git` file.
+- **The repository** is stored as `host/owner/name`, such as `github.com/example/app`. SSH and HTTPS remotes give the same value. The user name, any token or password, and the query in a remote URL are never sent.
+- **The path** is relative to the checkout's root. Without a checkout, it is relative to `GITHUB_WORKSPACE` or `CI_PROJECT_DIR` when the file is inside it, then relative to the working directory. Outside the working directory, it is the file's absolute path, and the agent prompt then says to find the file by its key.
+- **When nothing is available** for the repository or the ref, the upload leaves out that part, and PostHog keeps the value it stored before. Source detection never fails or blocks a send.
+- `posthog-node` caches what it detects per process. In development, a commit that you make after the first upload shows only after a restart.
+
+Over HTTP and the MCP, send `source` yourself: the repository from `git remote get-url origin`, as `host/owner/name` without credentials, the path relative to the repository's root, and the ref from `git rev-parse HEAD`.
+
+## Adopt a template made in PostHog
+
+You can move a template that you made in PostHog into code without changing its ID, workflow, sender, or history. Give it a key with a `PATCH` whose only field is `key`:
+
+```bash
+curl -X PATCH "/api/projects/:project_id/messaging_templates/:id/" \
+ -H "Authorization: Bearer $POSTHOG_PERSONAL_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{ "key": "welcome" }'
+```
+
+- Use a personal API key or the app, with editor access to the template's workflow. A project secret API key can't set a key.
+- A key is set once. A template that has a key keeps it.
+- PostHog never matches a template by its name. An upload under an unknown key always creates a new template.
+
+The next upload under that key claims the template: it publishes the uploaded content and marks the template as managed by code.
+
+When you delete a template, its key is free again. The next upload under that key creates a new template.
+
+## Errors
+
+Every error has a JSON body with a stable `code`:
+
+```json
+{
+ "type": "invalid_request",
+ "code": "template_not_found",
+ "detail": "No template in this project has the key `welcom`.",
+ "attr": "template__key",
+ "why": "A send names a template of this project that is not deleted.",
+ "fix": "Upload the template first with `PUT /api/projects/@current/messaging_templates/by_key/welcom/`, or check the key and the project.",
+ "docs_url": "https://posthog.com/docs/workflows/transactional-email-api#template_not_found"
+}
+```
+
+- `detail` says what happened, `why` says why, and `fix` says what to change.
+- `attr` names the request field the error is about, such as `subject` or `template__key` (`template.key`), or is `null`.
+- `docs_url` links to the section for the code below.
+- Only these codes carry `why`, `fix`, and `docs_url`: `invalid_template_key`, `invalid_request`, `template_not_found`, `template_outdated`, `immutable`, `key_already_set`, `key_in_use`, and the codes of a [change request](#run-with-posthog-code) (`instruction_missing`, `template_not_managed_by_code`, `template_repository_missing`, `template_repository_unsupported`, `github_repository_not_connected`, `posthog_code_not_available`, and `posthog_code_run_failed`).
+- All other codes have only `type`, `code`, `detail`, and `attr`: the send pipeline's codes (`template_not_sendable`, `sender_unavailable`, `marketing_template`, `template_not_transactional`, `transactional_email_paused`, `invalid_recipient`, `quota_limited`, `delivery_unavailable`) and the codes shared with the rest of the PostHog API (`not_found`, `authentication_failed`, `not_authenticated`, `permission_denied`, `throttled`). Their sections below give the fix.
+
+In `posthog-node`, `error.code` is the API's `code`, `error.message` is its `detail`, `error.field` is its `attr`, and `error.docsUrl` is its `docs_url`. For codes without `docs_url`, `error.docsUrl` is `undefined`, so look up `error.code` on this page.
+
+### `invalid_template_key`
+
+`400` on the upload, the send, and adoption.
+
+The key has a character other than a letter, a digit, a hyphen (`-`), or an underscore (`_`), or has more than 400 characters. The API says: "The key can have at most 400 letters, digits, hyphens (-) and underscores (_)."
+
+**Fix:** Use a key like `password-reset`. Code names the template by its key in every project, so the key must stay the same. `posthog-node` checks the key before it sends a request.
+
+### `invalid_request`
+
+`400` on the upload, the send, adoption, and a change request.
+
+A field is missing or has the wrong type. `attr` names the field, and `detail` starts with it, for example `subject: This field is required.` On the send, `template` must name exactly one of `key` and `id`, and `hash` only goes with `key`. On adoption, `key` must be the only field of the `PATCH`. On a change request, `instruction` has at most 2,000 characters.
+
+**Fix:** Fix the field that `attr` names, then send the request again. The API says: "Fix `subject` and upload again.", "Fix `template` and send again.", or "Fix `instruction` and request the change again."
+
+### `template_not_sendable`
+
+`400` on the upload and the send.
+
+The template has no subject or no body, so it can't be sent. The API says, for example: "The template has no email subject, so it can't be sent."
+
+**Fix:** Send a subject and HTML. In a template file, put the subject in ``.
+
+### `sender_unavailable`
+
+`400` on an upload that has to make the template transactional: the first upload of a key, or an upload that claims an adopted template or a template whose workflow was deleted.
+
+The project has no verified default sender, so PostHog can't make the template transactional. The upload writes nothing. The API says: "The project has no verified default email sender. Verify a sending domain, set it as the default sender, and upload again."
+
+**Fix:** [Verify a sending domain](/docs/workflows/configure-channels), click **Make default** next to the sender, and upload again.
+
+### `marketing_template`
+
+`400` on the upload and the send.
+
+The template is in a marketing message category. Transactional emails skip opt-outs, so a marketing template can't be sent this way. The API says: "The template is in a marketing category. Only transactional templates can be sent."
+
+**Fix:** Move the template to a transactional category, or remove its category. If the template is managed by code, [stop managing it in code](#templates-managed-by-code) first, or change the category's type.
+
+### `key_already_set`
+
+`400` on adoption.
+
+The template already has a key, and a key never changes. The API says, for example: "This template already has the key `welcome`."
+
+**Fix:** The API says: "Use `welcome` in your code, or duplicate the template and give the copy a key."
+
+### `key_in_use`
+
+`400` on adoption.
+
+Another template in this project has this key. A key names one template in a project. The API says, for example: "Another template in this project has the key `welcome`."
+
+**Fix:** The API says: "Pick another key, or upload your file to the template that has this key."
+
+### `immutable`
+
+`403` on an update, a design edit, or a delete of a template that is managed by code.
+
+The template's content comes from a file in a repository, and the next upload of that file replaces changes made in PostHog. The API says: "This template is managed by code, so it can't be changed in PostHog."
+
+**Fix:** The API names the file when it knows it, for example: "When the repository is in your workspace, or you run outside PostHog Code and can check it out, edit `emails/welcome.html` in `github.com/example/app` there, as the template's `change_prompt_context` describes. It also says how the change goes live. Inside PostHog Code, such as PostHog AI or the Slack bot, share the template's link instead: on its page, the person clicks Edit with your agent, then Run with PostHog Code. Otherwise, ask PostHog Code to open a pull request with the change: call the `workflows-request-email-template-change` tool, or POST an `instruction` to this template's `request_change/` route. To edit the template in PostHog instead, stop managing it in code first: send a PATCH whose only field is `managed_by` set to `gui`." See [change a code-managed template](#change-a-code-managed-template).
+
+### `template_not_found`
+
+`404` on the send.
+
+No template in this project has this key or ID, or the template is deleted. The API says, for example: "No template in this project has the key `welcome`."
+
+**Fix:** For a key, the API says: "Upload the template first with `PUT /api/projects/@current/messaging_templates/by_key/welcome/`, or check the key and the project." For an ID: "Check the id and the project." `posthog-node` uploads a template file and sends again on its own.
+
+### `template_outdated`
+
+`409` on a send with a content hash.
+
+PostHog never published the content that this hash describes, so it sent nothing. Sending the current content could send the wrong email. The API says: "PostHog does not hold the content this hash describes, so nothing was sent."
+
+**Fix:** The API says: "Upload the template, then send again. posthog-node does this on its own." For an automatic upload, set [`if_not_superseded`](#automatic-uploads-and-if_not_superseded).
+
+### `not_found`
+
+`404` on the upload route, the send routes, and the change request route.
+
+The transactional email API isn't enabled for this project, or the template ID in the path doesn't exist. Adoption and release don't check the beta flag, so a successful adoption doesn't mean the API is enabled. The API says: "Not found."
+
+**Fix:** The API is in beta. [Ask us in-app](https://app.posthog.com/#panel=support%3Afeedback%3A%3Alow%3Atrue) to enable it for your project. Don't retry until it's enabled. If it's already enabled, check the project and the template ID.
+
+### `authentication_failed`
+
+`401` on every route.
+
+PostHog doesn't accept the key. For example, the key is wrong, deleted, or rolled. A missing `Authorization` header answers `401` with the code `not_authenticated`.
+
+**Fix:** Send a project secret API key (`phs_`) as `Authorization: Bearer `. In `posthog-node`, set the `secretKey` option.
+
+### `permission_denied`
+
+`403` on every route.
+
+The key can't do this. For example:
+
+- "API key missing required scope 'hog_flow:write'": give the key the `hog_flow:write` scope. A [change request](#run-with-posthog-code) needs `hog_flow:read` and `task:write` instead, and names the first one the key lacks, such as "API key missing required scope 'task:write'".
+- "This action does not support project secret API key access": a project secret API key can only upload and send. Use a personal API key for other template routes, such as [adoption](#adopt-a-template-made-in-posthog) and a [change request](#run-with-posthog-code).
+- "You need editor access to this template's transactional email workflow.": a personal API key or a session needs editor access to the template's workflow.
+- "You need editor access to workflows to upload a new template.": a personal API key or a session needs editor access to Workflows to create a template.
+- "You need editor access to workflows to make a template transactional.": a personal API key or a session needs editor access to Workflows to make a template transactional.
+- "You do not have editor access to this resource.": a personal API key or a session needs editor access to Workflows for this route, such as a [change request](#run-with-posthog-code).
+- "API key does not have access to the requested project: ID ...": the key can't access the project in the URL. With a project secret API key, use `@current`. With a personal API key, use the ID of a project that the key can access.
+
+**Fix:** For the upload and send routes, use a project secret API key with `hog_flow:write` for the project that has the template. For other routes, use a personal API key with `hog_flow:write`, or with `hog_flow:read` and `task:write` for a [change request](#run-with-posthog-code), and the access that the route's section names.
+
+### `throttled`
+
+`429` on every route.
+
+Too many calls in a short time. The upload route and both send routes share one limit per API key, and one across all project secret API keys of a project. The API says, for example: "Request was throttled. Expected available in 30 seconds."
+
+**Fix:** Wait for the number of seconds in the `Retry-After` header, then send again.
+
+### `template_not_transactional`
+
+`400` on the send.
+
+The template has no workflow to send it, for example a template made in PostHog that you gave a key but never uploaded. The API says: "The template isn't transactional. Make it transactional to send it."
+
+**Fix:** Upload the template by its key, or make it transactional in PostHog.
+
+### `transactional_email_paused`
+
+`400` on the send.
+
+The template's workflow is paused or deleted.
+
+- "The template's transactional email isn't active.": the workflow is paused. **Fix:** Turn the template's workflow back on in Workflows. An upload never turns a paused workflow back on.
+- "The template's transactional email no longer exists.": the workflow was deleted. **Fix:** Upload the template again. The upload creates a new workflow with the project's default sender. For a template without a key, call `POST /api/projects/:project_id/messaging_templates/:id/make_transactional/` with a personal API key that has `hog_flow:write` and editor access to Workflows.
+
+### `invalid_recipient`
+
+`400` on the send.
+
+PostHog can't use the recipient address. The API says: "The recipient address can't contain { or }." An address that isn't a valid email address answers [`invalid_request`](#invalid_request) with `attr` set to `to`.
+
+**Fix:** Send one valid email address in `to`.
+
+### `quota_limited`
+
+`429` on the send.
+
+The project is over its email quota. The API says: "The project is over its email quota."
+
+**Fix:** Check your [sending allowance](/docs/workflows/sending-reputation), or wait for the next period.
+
+### `delivery_unavailable`
+
+`503` on the send.
+
+PostHog couldn't confirm that the email was queued. The API says: "The email pipeline is unavailable. Try again shortly."
+
+**Fix:** Send again later. Sends aren't idempotent, so a retry can send a second email if the first one was queued. `posthog-node` never retries this on its own.
+
+### `instruction_missing`
+
+`400` on a [change request](#run-with-posthog-code).
+
+The request has no `instruction`, or it is blank. The API says:
+
+- `detail`: "The request has no instruction."
+- `why`: "PostHog Code needs to know what to change in the template's file."
+- `fix`: "Send the change in `instruction`, such as `Make the button green.`"
+
+### `template_not_managed_by_code`
+
+`400` on a [change request](#run-with-posthog-code).
+
+The template was made in PostHog, or it is no longer managed by code, so there is no file to change. The API says:
+
+- `detail`: "This template is not managed by code, so it has no file for PostHog Code to change."
+- `why`: "Only a template uploaded from a file in a repository has a file to change."
+- `fix`: "Edit the template in PostHog instead: in the template editor, or with a PATCH to the template."
+
+### `template_repository_missing`
+
+`400` on a [change request](#run-with-posthog-code).
+
+No upload of the template sent a repository, so PostHog Code doesn't know where to open the pull request. The API says:
+
+- `detail`: "PostHog doesn't know which repository this template's file is in."
+- `why`: "The uploads of this template sent no `source.repository`."
+- `fix`: "Upload the template again with `source.repository`, such as `github.com/example/app`. posthog-node detects it from the git checkout or the CI environment when the upload doesn't set it."
+
+See [where the source details come from](#where-the-source-details-come-from).
+
+### `template_repository_unsupported`
+
+`400` on a [change request](#run-with-posthog-code).
+
+The template's repository isn't on GitHub, or isn't stored as `github.com//`. The API says, for example:
+
+- `detail`: "PostHog Code can't change files in `gitlab.com/example/app`." When the stored repository doesn't look like one: "PostHog Code can't change files in this template's repository."
+- `why`: "PostHog Code opens pull requests only on GitHub repositories, stored as `github.com//`."
+- `fix`: "Edit the template's file in its repository, as its `change_prompt_context` describes."
+
+[Edit with your agent](#edit-with-your-agent) works for any repository.
+
+### `github_repository_not_connected`
+
+`400` on a [change request](#run-with-posthog-code).
+
+The project's GitHub integration can't reach the template's repository. The API says, for example:
+
+- `detail`: "This project's GitHub integration can't reach `example/app`."
+- `why`: "PostHog Code works only in repositories that the project's GitHub integration can reach."
+- `fix`: "Connect GitHub in the project's integrations and give the PostHog app access to `example/app`, then request the change again. Access you just gave can take a few minutes to show up."
+
+### `posthog_code_not_available`
+
+`403` on a [change request](#run-with-posthog-code).
+
+PostHog Code can't start a task for this request. The API says: "PostHog Code is not available to start this change." The `why` and `fix` name the reason:
+
+| `why` | `fix` |
+| --- | --- |
+| "A PostHog Code task runs at a person's request, so a task already running can't start another one." | "Share the template's link with the person. On its page, they click Edit with your agent, describe the change, and click Run with PostHog Code, which opens a pull request." |
+| "PostHog couldn't check whether this organization can use PostHog Code." | "Try again in a few minutes." |
+| "This organization can't use PostHog Code." | "Edit the template's file in its repository, as its `change_prompt_context` describes." |
+| "This organization used up its PostHog Code credits." | "Edit the template's file in its repository, as its `change_prompt_context` describes. PostHog Code is available again when the credits renew." |
+
+[PostHog AI and the Slack bot](#ask-posthog-ai-or-the-slack-bot) get the first row when they call this route, because they run inside PostHog Code. An agent that gets it doesn't retry. It gives the person the link to the template, which is the last line of the template's `change_prompt_context`.
+
+### `posthog_code_run_failed`
+
+`400` on a [change request](#run-with-posthog-code).
+
+PostHog Code refused to start the task. `detail` is the reason PostHog Code gave, or "PostHog Code could not start the task." when it gave none. The API says:
+
+- `why`: "PostHog Code refused to start the task."
+- `fix`: "Fix what the detail names, then request the change again."
+
+### `template_superseded`
+
+A warning, not an error, in the `202` body of a send:
+
+```json
+{
+ "message_id": "01a146ff-acd6-0001-cb61-b4d68ddaaed2",
+ "warnings": [
+ {
+ "code": "template_superseded",
+ "detail": "The email was sent with the template's current content, because newer content replaced the content you hold.",
+ "fix": "Deploy the newer file, or upload your file to publish it again."
+ }
+ ]
+}
+```
+
+The email went out with the template's current content, because newer content replaced the content of your file. This is normal during a [rolling deploy](#rolling-deploys).
+
+**Fix:** The API says: "Deploy the newer file, or upload your file to publish it again." To publish your file on purpose, call `uploadTemplate()` in Node.js, or upload it without `if_not_superseded`. In `posthog-node`, the warning is in `data.warnings` as `{ code, message }`.
+
+### Errors from `posthog-node`
+
+`posthog-node` returns these codes without a call to PostHog, or when the call didn't reach PostHog:
+
+#### `missing_secret_key`
+
+The client has no `secretKey`. **Fix:** Set the `secretKey` option to a project secret API key with `hog_flow:write`.
+
+#### `client_disabled`
+
+The client is disabled, so nothing was sent. **Fix:** Turn off the client's `disabled` option where you want to send email.
+
+#### `network_error`
+
+The request didn't reach PostHog, or no answer came back. The email may or may not be queued. **Fix:** Check the network and the `host` option. Sends aren't idempotent, so a retry can send a second email.
+
+#### `template_file_unreadable`
+
+`posthog-node` can't read the template file, or the runtime has no file system. **Fix:** Check the path, and [ship the file with your deployment](#ship-template-files-with-serverless-bundles). On an edge runtime, send by key.
+
+#### `template_missing_subject`
+
+The template file has no ``. **Fix:** Add a `` with the subject line.
+
+#### `api_error`
+
+PostHog answered with an error that has no `code`, or with a body that `posthog-node` can't read. **Fix:** Check `error.statusCode` and `error.message`.
+
+#### `unexpected_error`
+
+The call failed in an unexpected way. **Fix:** Check `error.message`. If it continues, [contact us in-app](https://app.posthog.com/#panel=support%3Afeedback%3A%3Alow%3Atrue).
diff --git a/src/navs/index.js b/src/navs/index.js
index ceed3c782ddb..591d8ef40fae 100644
--- a/src/navs/index.js
+++ b/src/navs/index.js
@@ -8498,6 +8498,19 @@ export const docsMenu = {
icon: 'IconSend',
color: 'orange',
},
+ {
+ name: 'Transactional email',
+ },
+ {
+ name: 'Send email from code',
+ url: '/docs/workflows/transactional-email-api',
+ icon: 'IconLetter',
+ color: 'blue',
+ badge: {
+ title: 'Beta',
+ className: 'uppercase !bg-blue/10 !text-blue !dark:text-white !dark:bg-blue/50',
+ },
+ },
{
name: 'Channels',
},