Skip to content

docs(workflows): send content from your code on the transactional email API, Node.js and Python pages - #21819

Draft
Silthus wants to merge 22 commits into
masterfrom
docs/transactional-email-api-code-form
Draft

Silthus wants to merge 22 commits into
masterfrom
docs/transactional-email-api-code-form

Conversation

@Silthus

@Silthus Silthus commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Changes

An engineer who renders their own emails, such as React Email components, finds no way in the transactional email docs to send that content through PostHog. The pages cover only templates made in PostHog.

  • Send content from your code on the API page: a field table for template_key, subject, html, text, from, reply_to and capture_engagement, a cURL example, and the checks of a code send.
  • The Node.js and Python pages get the same section with an SDK example. The Node.js example renders a React Email component with @react-email/render and sends the result as html.
  • Link emails to your users on the API, Node.js and Python pages: pass the distinct ID you pass to identify(), so the send and its sent, opened, and clicked events land on that user's person. Without it, PostHog records the email against a person whose distinct ID is the recipient's email address, as master does today (distinct_id or to). Someone who isn't a user yet, such as an invite, needs no distinct ID. The overview page and the API page's distinct_id rows say the same and link to the section.
  • delivery_unavailable gets its own section on the API page. A link to #delivery_unavailable opens it.
  • The pages document the first release of the code send: PostHog/posthog #127122 and #127123, posthog-js #5509, and posthog-python #1171. Local templates, attachments, copies, headers and tags are not in that release, so the pages leave them out.

This revision (2026-11-03) adds "Link emails to your users" (5cffbd0, 51f427b, and the style fixes in c42c7c3). It describes only what master does when distinct_id is missing.

The revision before (2026-11-02) matches the pages to the first release:

  • capture_engagement leaves the template-send table. The first release refuses it on a template_id send, with every other field of content from your code.
  • sender_not_allowed folds into sender_not_verified, the code the first release returns for a from that no verified sender can use. sender_not_allowed comes later, with #127181.
  • A field error is a 400 with type: validation_error, a field code such as required, and attr. The page said invalid_request, which comes later too.
  • A replay also compares the sender PostHog chose. A retry without from after the default sender changed returns idempotency_key_reused.
  • The Node.js and Python sections are new.

Note

🔵 Merge risk: low · two-way door

  • Docs only: four pages, +154/-3. No component, navigation, or build change.
  • Merge it after posthog-node and posthog-python release the code form. The server code form deploys before both SDK releases.

Warning

The SDK pages name posthog-node 5.62.0 and posthog 7.83.0, the next minor release of each. Confirm both numbers when the SDKs release, before this merges.

Checklist

  • I've read the docs and/or content style guides.
  • Words are spelled using American English
  • Use relative URLs for internal links
  • I've checked the pages added or changed in the Vercel preview build
  • If I moved a page, I added a redirect in vercel.json (no page moved)

Evidence

  • SDK samples run against the SDK heads. The Node.js sample ran against posthog-node built from Add tabs to install.mdx #5509 at 933d907f3, with @react-email/render 1.1.2. It sends one POST to /api/projects/@current/messaging/send/email/ with the flat body, and tsc passes. The Python sync and async samples ran against posthog-python at Update events.md #1171's 20fad8c, with the same body. Raw output below.
  • Field facts come from the server at the first-release heads: SendEmailSerializer on #127123 at 51409d9b86b, and the replay fingerprint on #127122 at b5889c9eeca.
  • Preview build of 2610e8d (api, node, python): the API page renders the #delivery_unavailable and #send-content-from-your-code anchors, sender_not_verified, validation_error and the template-send sentence, and no sender_not_allowed. The Node.js and Python pages render their own #send-content-from-your-code section with the link to the API page.
  • Preview build of c42c7c3 (index, api, node, python): the API, Node.js and Python pages render #link-emails-to-your-users with the identify() sentence, the email-address bullet and the not-a-user-yet bullet. The API page's two distinct_id rows link to it, and the overview renders the new distinct ID item.
  • Lint: Vale 3.13.1 with the repo's config: 0 alerts on the four pages at c42c7c3. scripts/fix-mdx.js leaves them unchanged. CI runs Vale, markdownlint and spelling too.
  • Not checked: a live send against a deployed server, because the first release isn't deployed.
Sample runs (raw)

The pages' samples are the same on 7295a66a7e and this head; 2610e8d changed only API page text.

# Head 7295a66a7e8f052fea70701e50ec97d447fd9981. Node sample of node.mdx run against posthog-node built from PostHog/posthog-js#5509 head 933d907f3c1d932a666769264342196c893639e2 (pnpm pack), @react-email/render 1.1.2
$ tsx sample.tsx && tsc -p .
id: msg_1
POST https://us.posthog.com/api/projects/@current/messaging/send/email/ Idempotency-Key=password-reset-8f14e45f
body keys: capture_engagement,distinct_id,from,html,subject,template_key,to
html starts: <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.or
both forms -> invalid_form | Send either templateId, or templateKey with subject and html or text.
templateData on code form -> invalid_form | A send with templateKey doesn't take templateData, so no email was sent.
tsc exit=0

# Head 2610e8d1d1db1b36c8b02308196ffd851c2a277d (python.mdx unchanged since 7295a66a7e). Python samples of python.mdx against posthog-python at PostHog/posthog-python#1171 head 20fad8c767d0be1c45047ca8b4123e23e53710d8.
# Rerun of live-run-python-7295a66a7e.txt with the harness reading headers case-insensitively: httpx lowercases header names, so the earlier run printed an empty async Idempotency-Key.
# posthog: 7.82.0 /home/coder/dev/m461-docs/proof/py/.venv/lib/python3.14/site-packages/posthog
# install: {"url":"https://github.com/PostHog/posthog-python","vcs_info":{"vcs":"git","commit_id":"20fad8c767d0be1c45047ca8b4123e23e53710d8","requested_revision":"20fad8c767d0be1c45047ca8b4123e23e53710d8"}}
$ .venv/bin/python -I py_sample_ci.py
[PostHog] Multiple active PostHog clients detected for the same project API key and host. Reuse one client per application when possible.
sync id: msg_1
async id: msg_2
sync https://us.posthog.com/api/projects/@current/messaging/send/email/ Idempotency-Key=password-reset-8f14e45f {"capture_engagement": true, "distinct_id": "user_123", "from": "Hedgebox <[email protected]>", "html": "<p>Reset</p>", "subject": "Reset your password", "template_key": "password-reset", "to": "[email protected]"}
async https://us.posthog.com/api/projects/@current/messaging/send/email/ Idempotency-Key=password-reset-8f14e45f {"capture_engagement": true, "distinct_id": "user_123", "from": "Hedgebox <[email protected]>", "html": "<p>Reset</p>", "subject": "Reset your password", "template_key": "password-reset", "to": "[email protected]"}
TypeError for both forms: Pass either template_id or template_key.
TypeError for template_data on code form: send() got arguments that the code form doesn't take: template_data.

Decisions

Question Answer Who Where
Which fields to document Only the first release's: no local templates, attachments, copies, headers or tags 👤 Human Silthus/posthog#461
React The app renders with @react-email/render and sends html; the SDK adds no React option 👤 Human Silthus/posthog#461
capture_engagement on a template send Not documented: the first release refuses it there (replaces the earlier row, which documented a server slice that is parked) 🤖 Agent this PR
SDK sections on the Node.js and Python pages Added here, so each SDK page shows both forms 🤖 Agent this PR

🤖 Agent context

Agent: Claude Code, Claude Opus 5.5 (claude-opus-5-5), as the docs, skills and wizard slice of Silthus/posthog#461. Skills: full-stop, writing-user-facing-copy, writing-pr-descriptions. Reviews in fleet proof: GPT-6.1 Sol (high), round 1 on 7295a66a7e (2 findings, both fixed in 2610e8d1d1) and round 2 on the fixes (no findings). Round 3 on 5cffbd0 (the new section) and final reads on 51f427b and c42c7c3: no findings.

Earlier revisions of this description

The two "Impact" paragraphs of the earlier text are removed: they named internal metrics. The rest is unchanged.

Changes

Moved from Silthus#1.

This PR adds the code form of the transactional email send to the API page that PostHog/posthog.com#21564 adds. With the code form, your code sends the subject and the body, and PostHog needs no template. The PR also adds the checks that apply only to the code form, and a delivery_unavailable section. The docs_url of the send route's 503 opens that section.

The PR stacks on posthog/docs-transactional-email-api, the branch of #21564. It changes one page: contents/docs/workflows/transactional-emails/api.mdx, +59/-1. Before, this PR rewrote our own copy of the page. #21564 now owns that page, so this PR keeps only the part that #21564 does not have. Silthus/posthog.com#2 duplicated #21564 and is closed.

Slice 13 of Silthus/posthog#461.

[!NOTE]
🔵 Merge risk: low · two-way door

  • delivery_unavailable. A new section under "Send an email". It tells the reader that PostHog maybe queued the email, and to retry with the same Idempotency-Key. The SDKs retry this on their own. The old sentence about the 503 moved into this section.
  • Send content from your code. A new section with the field table (template_key, to, distinct_id, subject, html, text, from, reply_to, cc, bcc, headers, tags, attachments, capture_engagement), the rules for headers, tags, attachments, and suppressed addresses, and a cURL example.
  • capture_engagement. The code form and a template send both take it, so both tables list it. For content from your code, the default is true. For a template send, the default is the project's Capture email engagement events setting, and a retry with the same Idempotency-Key must send the field as the first request did. Like the code form, this documents the send route of the server stack, not master.
  • Checks for content from your code. A table of the three refusals that apply only to the code form: invalid_request, sender_not_verified, and sender_not_allowed. The errors of every send stay on the overview page.
  • The body fields are flat and at the top level, the same as the template form on docs(workflows): add the transactional email API page #21564.
  • Field rules that match the send route (review fixes 831841b73, b42086e4c, acd4a4b88): reply_to takes addresses without a display name. The reserved headers include DKIM-Signature and Received. A header name has 1 to 126 visible ASCII characters, with no spaces and no colon; a value has at most 870 printable ASCII characters and can have spaces. A tag name or value has at most 256 characters.
  • This revision (2502eb65a and 673346e75, review fixes of rounds 5 and 6): a code-form retry with the same Idempotency-Key is the same request when every field of the table has the same value. template_key groups the email's metrics and sends. PostHog refuses a tag name that starts with ph_ in uppercase or lowercase. The template-send table gets a capture_engagement row. A double blank line after the checks table is gone (scripts/fix-mdx.js).

Checklist

  • I've read the docs and/or content style guides.
  • Words are spelled using American English
  • Use relative URLs for internal links
  • I've checked the pages added or changed in the Vercel preview build
  • If I moved a page, I added a redirect in vercel.json (no page moved)

Evidence

  • Links. Each new link points to a page or an anchor that is on this branch: #send-an-email, #checks-for-content-from-your-code, /docs/workflows/transactional-emails#errors, /docs/workflows/engagement-events, /docs/workflows/opt-outs#the-suppression-list, and /docs/workflows/configure-channels. I checked each one in the source.
  • Strings. The field limits and the refusal codes come from the send route on PostHog/posthog master and the code-form branch of the stack. The same-request rule follows the send route's idempotency check. The ph_ rule follows the tag name check of the route and of the CDP schema, which both ignore case. The capture_engagement defaults follow the route's serializer. The header name rule was checked against the route's header check for every ASCII code point, the empty name, and 126- and 127-character names.
  • Flat send body (checked on 673346e75, 2026-11-02): feat(messaging): take template_id and distinct_id at the top of the send body posthog#126287 put template_id and distinct_id at the top of the send body on master. Both cURL examples on the page already send that shape, so this head needs no change. The base stays on docs(workflows): add the transactional email API page #21564's branch.
  • Lane gate on 673346e75: prettier --check passes (.prettierignore skips .mdx). scripts/fix-mdx.js, the format:docs step, leaves the page unchanged (it removed one double blank line in 2502eb65a). Vale 3.13.1 with the repo's .vale.ini: 0 alerts on the page. pnpm format's glob does not include .mdx.
  • Rendered page (live run): the preview build of this PR at 673346e75. Headless Chromium opened #delivery_unavailable, #send-content-from-your-code and #checks-for-content-from-your-code: each heading renders and is visible, and the page text has capture_engagement, Idempotency-Key, sender_not_verified, sender_not_allowed and the ph_ rule.
  • CI: green except Spelling, which fails before it runs: the base branch of docs(workflows): add the transactional email API page #21564 still sets up Python 3.8, which the runner image no longer has. master fixed that in Bump spelling check to Python 3.12 #21771, so the job passes once docs(workflows): add the transactional email API page #21564 takes master. The same codespell fork, run locally on the changed page with .codespellignore, reports nothing.
  • Gaps:
    • I did not run codespell or markdownlint locally. CI's Vale, markdownlint and spelling jobs run on this PR.
    • No full Gatsby build for this revision. The lane gate covers the page's markdown checks only.
    • No screenshots. The page has no new component, and the PR is not a visual change.

Decisions

Question Answer Who Where
How to document capture_engagement on a template send? Add a row to the template-send table with the project default and the retry rule. The panel first proposed refusing it on template sends, but the send route takes it there. 🤖 Agent pending approval
Keep our own API page or build on #21564? Build on #21564 and keep only the code form, capture_engagement, its checks, and the delivery_unavailable anchor. Close Silthus/posthog.com#2. 🤖 Agent Silthus/posthog#461

Involvement: no grilling rounds, no prototypes. GPT-6.1 Sol (high) reviewed this revision in round 1 (one finding on the reserved headers) and in three reads of the fixes: the first two found one gap each in the header name rule, both fixed, and the third was clean. QA swarm 2026-11-01: panel round 5 (fresh-eyes, correctness, risk, design, anti-overengineering, sweep, michael): 5 findings, 4 fixed, 1 rejected (the dev-server load, a recorded gap); fixes round 6 (fresh-eyes): 2 findings, both fixed; final read 1 (fresh-eyes) on 673346e75: one nit on this description, fixed here. 7 fixed, 1 rejected. Proof: source checks and Vale; the rendered page checked in this PR's preview build on 2026-11-02.

Follow-up: capture_engagement on a template send is not live: PostHog/posthog master does not take the field yet. It comes with the code-form send route of the server stack, which this PR waits for (see the merge-risk callout). When that route ships, the overview's same-request list (index.mdx, the base page) becomes wrong for a template send that sets capture_engagement, so its fix must land with this PR or before it.

🤖 Agent context

Agent: Claude Code, Claude Opus 5.5 (claude-opus-5-5). Reviews: GPT-6.1 Sol (high) and Claude Opus 5.5 (high) in fleet proof panels on the fork. The reviewed head 673346e75 is pushed here unchanged.

History: the earlier revision on our own page (before the rebase onto #21564)

No upstream PR: this page change lives only in the fork. It stacks on Silthus/posthog.com#2, which moved here from PostHog/posthog.com#21306 (closed on 2026-10-25).

Changes

Engineers who move transactional email off Resend need one page that shows how to send from code with the PostHog client they already use. The page taught the earlier posthog-node draft (templateFile(), uploadTemplate(), "sends are not idempotent"). It now teaches the final send contract of PostHog/posthog#120921, in Node.js and Python, and every error code that the server and the SDKs emit has an anchor.

Slice 13 of Silthus/posthog#461, strand Silthus/posthog#538.

[!NOTE]
🔵 Merge risk: low · two-way door

  • Docs only: one page. No component, sidebar, or build change.
  • The page documents the contract as decided. Several behaviors are not merged yet, so hold the merge until they ship (list below).

New in aed49fa8c to 641b3bd85: the final contract

The 2026-10-22 and 2026-10-23 entries of the contract, against the server and SDK branches of the PRs below.

  • Copies, headers, tags, and attachments on the code form: a new section with a Node.js and Python example, the field table, and the rules (reserved headers, tag characters, attachment limits and blocked file types, inline images by content ID, suppressed cc and bcc addresses). None of them work with template_id.
  • Unknown fields: a field the send route doesn't take answers 400 invalid_request, with the server's attr, detail, why, and fix. The HTTP section and the invalid_request section quote it.
  • transactional_email_paused: quotes the new server text. A paused workflow is checked after the idempotency key lookup, so a retry of a queued send still gets its 202. When a stored template's workflow is deleted, the send refuses before the key lookup. Rendered content whose key names no stored template gets a new workflow instead, so a retry answers 422 idempotency_key_reused.
  • The replay scope: a replay survives a paused workflow and a changed default sender. A deleted template, a changed local template, and a missing default sender still refuse.
  • idempotency_key_reused is the 422 code on both send routes. invalid_idempotent_request is its SDK name. One set of keys covers both routes.
  • HTML-only content: PostHog derives text from html. When the HTML has no text, such as an email that is only an image, the email goes out without a plain-text part.
  • The sender: Python names it from_. sender_not_allowed (the address isn't on a verified domain) and sender_not_verified (no from, and no verified default sender) quote the server text.
  • Read an email's status: GET messaging/emails/{id}/ and posthog.emails.get(id) in Node.js, the statuses in order, the optional reason, 30 days of retention, and the 404 for a new email. posthog-python has no read yet.
  • Find the emails sent to an address: GET messaging/emails/?to=<address>, short. Newest first and paged, as the facade of PostHog/posthog#121898 returns them. The API route and its contract amendment are pending (see Open).
  • SDK local checks: invalid_form, invalid_template_id, invalid_attachment, and invalid_message_id get anchors, with posthog-node's messages. network_error gets the name service_unavailable.
  • Scopes: a key with hog_flow:write also reads an email's status. A project secret API key uploads templates, sends them, and reads an email's status.

What the page covers

  • How it works: the template form by template_id with template_data, and the code form by template_key: rendered content, and local templates that the SDK uploads when they change.
  • Set up the client: secretKey / secret_key, appHost / app_host for any host that isn't PostHog Cloud (and why the secret key never goes to an ingestion proxy), maxRetries / max_retries.
  • Send a template made in PostHog and Send an email from your code: Node.js and Python side by side (MultiLanguage tabs), rendered content, React Email rendered to html, local templates and their sync loop, rolling deploys, the sender, and copies, headers, tags, and attachments.
  • Always pass distinct_id, and why. Every send example passes it.
  • What a successful send means: 202 is queued, not delivered. The checks that would skip a send run first.
  • Idempotency: the key on every call, the replay and its scope, the in-flight 409, the 422, what the SDKs retry, max_retries=0 with a key from the email's purpose for job runners, and the promise: one send per key on retries, not exactly-once delivery.
  • Engagement capture, results and errors in the SDKs (the shared error fields, { data, error } in Node.js, EmailError in Python, the status to name table with Resend's names), read an email's status, and send over HTTP (cURL with the wire fields and the Idempotency-Key header, and a parameter-to-request table).
  • Error sections for every code the server and the SDKs emit.
  • Removed: the templateFile() and uploadTemplate() sections, posthog-node's source detection, template_file_unreadable, template_missing_subject, and "Sends are not idempotent".

Not merged yet

The page documents these as decided. Hold this PR until they ship:

  • PostHog/posthog.com#21306, the base of this PR.
  • Server, PostHog/posthog:
    • #118924: upload by key, the send route, the content hash.
    • #121083 and #121084: the send policy and the send command.
    • #121272: the code form, template_edited_in_posthog, sender_not_verified, derived text.
    • #121850: the send checks before the 202 (sender_not_allowed, recipient_suppressed, sending_suspended, template_paused).
    • #121867: idempotency on the send route.
    • #121851 and #121852: copies, headers, tags, and attachments.
    • #121896, #121897, and #121898: the email status and its read by ID. The lookup by recipient isn't built yet.
  • posthog-node, PostHog/posthog-js: #5312 (template form), #5371 (code form), #5373 (copies, headers, tags, attachments), #5372 (emails.get).
  • posthog-python: #1093 (template form) and #1101 (code form).

Evidence

  • Test seam: the anchor check, a scratch Playwright script kept out of the repo, as on docs(workflows): add the transactional email API page #21306. It opens the page and asserts:
    • every code the server and the SDKs emit has an element with that id, and its section quotes the server's or the SDK's strings;
    • every error name of the contract is on the page;
    • no prose names a removed or superseded behavior, such as templateFile or "so every email has a text version" (quoted server text in code blocks is exempt);
    • every send example passes a distinct ID;
    • every in-page # link resolves.
  • Strings come from the server branches of the PRs below at origin/feat/email-status-read (2cb033ad613) and origin/feat/email-send-attachments, posthog-node at feat/node-emails-get, and posthog-python at feat/emails-code-form.
  • Red on 9e73da0bf (the page before this revision), checked on the page source: 20 failures. The new invalid_request, sender_not_verified, not_found, and transactional_email_paused texts were missing, the four local check codes had no heading, and the page still said "so every email has a text version".
  • Green on the production build of 931fdb8f1, served with gatsby serve: 48/48 codes anchored with matching text, 14 send examples all pass a distinct ID, 142 in-page links resolve. 641b3bd85 changes three sentences of prose only.
  • Build: pnpm build:minimal passes: 3,982 pages, exit 0.
  • Lint: Vale 3.13.1 with the repo's config: 0 errors on the page. Its warnings on new lines name packages or quote SDK text. codespell as CI runs it (-q 8 -I .codespellignore): no change. markdownlint passed in the pre-commit hook. No em or en dashes.
  • Gaps:
    • No live send against a server with the stack deployed. The page follows the code on the branches above.
    • The red run checks the old page's source, not a render, to save a second build. The green run checks the render.
    • Screenshots are light mode only, for the reason in the earlier set.

Screenshots

Production build (pnpm build:minimal, gatsby serve) at 931fdb8f1, light, 1440 px. Example data only: example.com addresses and invented IDs.

Section Screenshot
Add copies, headers, tags, and attachments Copies, headers, tags, and attachments
Idempotency: the replay scope and one set of keys Idempotency
Read an email's status, find the emails sent to an address Read an email's status
invalid_request: unknown fields, copies, headers, tags, attachments invalid_request
transactional_email_paused (before 641b3bd85 scoped the deleted-workflow sentence) transactional_email_paused
The SDK's local checks Local checks
docs_url for invalid_attachment, opened by plain navigation Error anchor
Anchor check output (green, production build of 931fdb8)
GET http://localhost:8538/docs/workflows/transactional-email-api -> 200
ok   #template_outdated (H3, 5 API strings)
ok   #template_not_found (H3, 2 API strings)
ok   #sender_not_verified (H3, 11 API strings)
ok   #recipient_suppressed (H3, 2 API strings)
ok   #sending_suspended (H3, 4 API strings)
ok   #template_paused (H3, 4 API strings)
ok   #template_edited_in_posthog (H3, 3 API strings)
ok   #idempotency_key_in_flight (H3, 1 API strings)
ok   #idempotency_key_reused (H3, 1 API strings)
ok   #invalid_idempotency_key (H3, 1 API strings)
ok   #invalid_recipient (H3, 1 API strings)
ok   #quota_limited (H3, 1 API strings)
ok   #delivery_unavailable (H3, 1 API strings)
ok   #invalid_request (H3, 9 API strings)
ok   #template_superseded (H3, 2 API strings)
ok   #invalid_template_key (H3, 0 API strings)
ok   #template_not_sendable (H3, 0 API strings)
ok   #sender_unavailable (H3, 0 API strings)
ok   #marketing_template (H3, 0 API strings)
ok   #key_already_set (H3, 0 API strings)
ok   #key_in_use (H3, 0 API strings)
ok   #immutable (H3, 0 API strings)
ok   #not_found (H3, 2 API strings)
ok   #authentication_failed (H3, 0 API strings)
ok   #permission_denied (H3, 0 API strings)
ok   #throttled (H3, 0 API strings)
ok   #template_not_transactional (H3, 0 API strings)
ok   #transactional_email_paused (H3, 4 API strings)
ok   #instruction_missing (H3, 0 API strings)
ok   #template_not_managed_by_code (H3, 0 API strings)
ok   #template_repository_missing (H3, 0 API strings)
ok   #template_repository_unsupported (H3, 0 API strings)
ok   #github_repository_not_connected (H3, 0 API strings)
ok   #posthog_code_not_available (H3, 0 API strings)
ok   #posthog_code_run_failed (H3, 0 API strings)
ok   #sender_not_allowed (H3, 3 API strings)
ok   #invalid_idempotent_request (H3, 0 API strings)
ok   #missing_secret_key (H4, 0 API strings)
ok   #missing_app_host (H4, 0 API strings)
ok   #app_host_required (H4, 0 API strings)
ok   #client_disabled (H4, 0 API strings)
ok   #network_error (H4, 0 API strings)
ok   #api_error (H4, 0 API strings)
ok   #unexpected_error (H4, 0 API strings)
ok   #invalid_form (H4, 0 API strings)
ok   #invalid_template_id (H4, 0 API strings)
ok   #invalid_message_id (H4, 0 API strings)
ok   #invalid_attachment (H4, 0 API strings)
14 send examples, 142 in-page links
48/48 codes anchored, all checks green
Anchor check output (red, source of 9e73da0)
== 9e73da0bf: 20 failures
  FAIL #sender_not_verified: not quoted: "The send names no `from`, and the project has no verified default sender to send from."
  FAIL #sender_not_verified: not quoted: "Pass `from` with an address on a verified domain, or set a default sender in the email cha"
  FAIL #invalid_request: not quoted: "PostHog doesn't take this field on this route."
  FAIL #invalid_request: not quoted: "so it never sends the email without part of what you asked for."
  FAIL #invalid_request: not quoted: "is not a bare address. Send it as `[email protected]`."
  FAIL #invalid_request: not quoted: "itself. Use the send's own fields, such as `from`, `cc` or `subject`, instead."
  FAIL #invalid_request: not quoted: "Use only ASCII letters, numbers, `_` and `-`."
  FAIL #invalid_request: not quoted: "Attachments can hold 512KB in total. Send a link to larger files."
  FAIL #invalid_request: not quoted: "files. Send a link to the file instead."
  FAIL #not_found: not quoted: "PostHog keeps an email's status for 30 days. A new email can take a few seconds to show up"
  FAIL #not_found: not quoted: "Check the `message_id` the send answered and the project, or read again in a few seconds."
  FAIL #transactional_email_paused: not quoted: "The email's workflow isn't an active transactional email."
  FAIL #transactional_email_paused: not quoted: "PostHog sends a transactional email only while its workflow is active."
  FAIL #transactional_email_paused: not quoted: "Turn the email's workflow back on, then send again."
  FAIL #invalid_form: no heading
  FAIL #invalid_template_id: no heading
  FAIL #invalid_message_id: no heading
  FAIL #invalid_attachment: no heading
  FAIL still says "templateFile"
  FAIL still says "so every email has a text version"
Earlier evidence, before the final contract (ce1b5ea to 6c55cf4)

Local dev server (GATSBY_MINIMAL=true pnpm start) at ce1b5ea56, before the review fixes, light, 1440 px. Example data only: example.com addresses and invented IDs.

Section Screenshot
Set up the client Set up the client
Send an email from your code: rendered content and React Email Send rendered content
Send a local template, its sync loop, rolling deploys Send a local template
Always pass distinct_id, what a successful send means distinct_id and 202
Idempotency Idempotency
Engagement capture, results and errors in the SDKs Errors in the SDKs
sender_not_verified to sending_suspended Send check errors
template_paused to idempotency_key_reused Idempotency errors
Errors from the SDKs SDK errors
docs_url for template_edited_in_posthog, opened by plain navigation Error anchor
Anchor check output (green, b7c65ae)
GET http://localhost:8538/docs/workflows/transactional-email-api -> 200
ok   #template_outdated (H3, 5 API strings)
ok   #template_not_found (H3, 2 API strings)
ok   #sender_not_verified (H3, 12 API strings)
ok   #recipient_suppressed (H3, 2 API strings)
ok   #sending_suspended (H3, 4 API strings)
ok   #template_paused (H3, 4 API strings)
ok   #template_edited_in_posthog (H3, 3 API strings)
ok   #idempotency_key_in_flight (H3, 1 API strings)
ok   #idempotency_key_reused (H3, 1 API strings)
ok   #invalid_idempotency_key (H3, 1 API strings)
ok   #invalid_recipient (H3, 1 API strings)
ok   #quota_limited (H3, 1 API strings)
ok   #delivery_unavailable (H3, 1 API strings)
ok   #invalid_request (H3, 2 API strings)
ok   #template_superseded (H3, 2 API strings)
ok   #invalid_template_key (H3, 0 API strings)
ok   #template_not_sendable (H3, 0 API strings)
ok   #sender_unavailable (H3, 0 API strings)
ok   #marketing_template (H3, 0 API strings)
ok   #key_already_set (H3, 0 API strings)
ok   #key_in_use (H3, 0 API strings)
ok   #immutable (H3, 0 API strings)
ok   #not_found (H3, 0 API strings)
ok   #authentication_failed (H3, 0 API strings)
ok   #permission_denied (H3, 0 API strings)
ok   #throttled (H3, 0 API strings)
ok   #template_not_transactional (H3, 0 API strings)
ok   #transactional_email_paused (H3, 0 API strings)
ok   #instruction_missing (H3, 0 API strings)
ok   #template_not_managed_by_code (H3, 0 API strings)
ok   #template_repository_missing (H3, 0 API strings)
ok   #template_repository_unsupported (H3, 0 API strings)
ok   #github_repository_not_connected (H3, 0 API strings)
ok   #posthog_code_not_available (H3, 0 API strings)
ok   #posthog_code_run_failed (H3, 0 API strings)
ok   #sender_not_allowed (H3, 0 API strings)
ok   #invalid_idempotent_request (H3, 0 API strings)
ok   #missing_secret_key (H4, 0 API strings)
ok   #missing_app_host (H4, 0 API strings)
ok   #app_host_required (H4, 0 API strings)
ok   #client_disabled (H4, 0 API strings)
ok   #network_error (H4, 0 API strings)
ok   #api_error (H4, 0 API strings)
ok   #unexpected_error (H4, 0 API strings)
12 send examples, 112 in-page links
44/44 codes anchored, all checks green
Anchor check output (red, 2b0a7a2)
GET http://localhost:8538/docs/workflows/transactional-email-api -> 200
FAIL #template_outdated: not quoted: "PostHog holds no content for this key yet."
FAIL #template_outdated: not quoted: "The template never had this content, so sending its current content could send the wrong email."
FAIL #template_not_found: not quoted: "A send names a template of this project that is not deleted."
FAIL #sender_not_verified: no element with this id
FAIL #recipient_suppressed: no element with this id
FAIL #sending_suspended: no element with this id
FAIL #template_paused: no element with this id
FAIL #template_edited_in_posthog: no element with this id
FAIL #idempotency_key_in_flight: no element with this id
FAIL #idempotency_key_reused: no element with this id
FAIL #invalid_idempotency_key: no element with this id
ok   #invalid_recipient (H3, 1 API strings)
ok   #quota_limited (H3, 1 API strings)
ok   #delivery_unavailable (H3, 1 API strings)
FAIL #invalid_request: not quoted: "A send needs a template, a recipient address, and data as an object."
FAIL #invalid_request: not quoted: "Fix `template` and send again."
ok   #template_superseded (H3, 2 API strings)
ok   #invalid_template_key (H3, 0 API strings)
ok   #template_not_sendable (H3, 0 API strings)
ok   #sender_unavailable (H3, 0 API strings)
ok   #marketing_template (H3, 0 API strings)
ok   #key_already_set (H3, 0 API strings)
ok   #key_in_use (H3, 0 API strings)
ok   #immutable (H3, 0 API strings)
ok   #not_found (H3, 0 API strings)
ok   #authentication_failed (H3, 0 API strings)
ok   #permission_denied (H3, 0 API strings)
ok   #throttled (H3, 0 API strings)
ok   #template_not_transactional (H3, 0 API strings)
ok   #transactional_email_paused (H3, 0 API strings)
ok   #instruction_missing (H3, 0 API strings)
ok   #template_not_managed_by_code (H3, 0 API strings)
ok   #template_repository_missing (H3, 0 API strings)
ok   #template_repository_unsupported (H3, 0 API strings)
ok   #github_repository_not_connected (H3, 0 API strings)
ok   #posthog_code_not_available (H3, 0 API strings)
ok   #posthog_code_run_failed (H3, 0 API strings)
FAIL #sender_not_allowed: no element with this id
FAIL #invalid_idempotent_request: no element with this id
ok   #missing_secret_key (H4, 0 API strings)
FAIL #missing_app_host: no element with this id
FAIL #app_host_required: no element with this id
ok   #client_disabled (H4, 0 API strings)
ok   #network_error (H4, 0 API strings)
ok   #api_error (H4, 0 API strings)
ok   #unexpected_error (H4, 0 API strings)
FAIL error name `validation_error` not on the page
FAIL error name `missing_api_key` not on the page
FAIL error name `invalid_permission` not on the page
FAIL error name `concurrent_idempotent_requests` not on the page
FAIL error name `invalid_idempotent_request` not on the page
FAIL error name `rate_limit_exceeded` not on the page
FAIL error name `monthly_quota_exceeded` not on the page
FAIL error name `service_unavailable` not on the page
FAIL error name `application_error` not on the page
FAIL prose still mentions "templateFile"
FAIL prose still mentions "uploadTemplate"
FAIL prose still mentions "Sends are not idempotent"
FAIL prose still mentions "template_file_unreadable"
2 send examples, 63 in-page links
30 failures

Open contract questions

Each is a place where the page, the contract, and the code don't agree yet. The page follows the contract unless the line says otherwise.

  1. posthog-python vs the contract (PostHog/posthog-python#1101): it answers app_host_required, not missing_app_host, the client's retry option is email_request_max_retries, send takes no per-send max_retries (so the job runner example doesn't run there yet), and the result field is id, where the contract says message_id. The page documents the contract names, uses result.id, and anchors app_host_required too.
  2. No source on SDK uploads. The spec says the SDK's upload "carries no link to the code", so the page drops posthog-node's source detection. A local template that only an SDK uploaded then has no repository, and Run with PostHog Code refuses it with template_repository_missing. Confirm, or the code form keeps source detection.
  3. Server texts that still name the old flow (quoted verbatim, so the page can't fix them): the template_repository_missing fix says "posthog-node detects it from the git checkout or the CI environment", and the change prompt says posthog.emails.templateFile(). Both need a server change in the change request stack.
  4. replayed in Node.js. The server returns replayed, and posthog-python exposes it. posthog-node's data is { id, warnings }, as in the contract. The page mentions replayed for Python and HTTP only.
  5. Copy HTML file. The copied file holds the subject in <title>. Sending it as a local template needs the app to pass subject and html separately, and whether html keeps the <title> changes the hash. The page says to pass the subject as subject. The byte-for-byte round trip isn't proven.
  6. The lookup by recipient (pending). The contract still lists the lookup by address under "Not in this contract", and no API route serves it yet. PostHog/posthog#121898 has the facade function (newest first, cursor pages). The page gives the route, the scope and rate limits of the read by ID, and one cURL example. Its paging parameters and errors follow once the contract amendment lands.
Closed since the last revision
  • The 422 code: idempotency_key_reused on both send routes, with the SDK name invalid_idempotent_request.
  • from in Python: from_.
  • sender_not_allowed: the send checks define it, with server text. The page quotes it.
  • Overlapping codes: transactional_email_paused (the workflow) and template_paused (the send policy) both stay, with separate meanings. So do sender_unavailable (the upload) and sender_not_verified (the send).
  • The name of network_error: service_unavailable.
  • posthog-python's app_host: the client takes it now. The rest of that question stays open as question 1.

Decisions

Question Answer Who Where
Where does the page change land? A draft PR on the fork, stacked on the fork mirror of #21306, instead of commits on #21306 itself. 🤖 Agent pending approval
What does the page document before the code ships? The contract of PostHog/posthog#120921 as decided. Unmerged PRs are listed here, not on the page, and the merge waits for them. 🤖 Agent pending approval
Which code does an error section use when the contract and the server differ? Both get an anchor, so every docs_url the server builds today and after the contract lands opens a section. 🤖 Agent pending approval
Does the page keep posthog-node's source detection? No. The spec says an SDK upload carries no link to the code. Open question 2 asks to confirm. 🤖 Agent pending approval
What does the page call the Python sender parameter? from_, as the contract now names it. Replaces from with no Python example. 🤖 Agent pending approval
How much of the lookup by recipient does the page document? The route and one cURL example, marked as being built, until the contract amendment lands. 🤖 Agent pending approval

Open: the 6 contract questions above.

Involvement: 0 grilling rounds, 0 prototypes, 2 GPT-6.1 Sol review rounds (3 findings in the first, 2 in the second, all fixed), 6 final reads of the fixes (reads 1, 2, 4, and 5 found 1 finding each, all fixed; read 3 ran after Michael overrode the arbiter and was clean; read 6 was clean), proof captured (anchor check red and green, production build, Vale, codespell, markdownlint, screenshots).

Checklist

  • I've read the docs and/or content style guides.
  • Words are spelled using American English
  • Use relative URLs for internal links
  • I've checked the pages added or changed in the Vercel preview build
  • If I moved a page, I added a redirect in vercel.json

Written by Claude Opus 5.5 (claude-opus-5-5) as the docs-api build lane of the map. Reviewed by GPT-6.1 Sol (high).

🤖 Generated with Claude Code

🤖 Generated with Claude Code

mayteio and others added 14 commits October 24, 2026 10:38
Document how to make a Workflows email template transactional and send it with POST /api/projects/:project_id/messaging_templates/:template_id/send/. The page covers the API key, the request and response, idempotency keys, error codes, rate limits, and how transactional emails behave. The page is marked beta.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

Generated-By: PostHog Desktop
Task-Id: a0274fa0-d9be-48bf-b601-f153decf241d
…e SDKs

- Send through `POST /messaging/send/email/` with `template.id`. The template route still works.
- Read an email's status with `GET /messaging/emails/:message_id/`: statuses, reasons, 404 cases, scopes and read limits.
- Send and read with the Python and Node.js SDKs.
- Add `recipient_unreachable` to the errors table.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

Generated-By: PostHog Desktop
Task-Id: e72a2c10-f145-4f7c-8754-a4fcc06a18be
Co-Authored-By: Claude Opus 5.5 <[email protected]>

Generated-By: PostHog Desktop
Task-Id: e72a2c10-f145-4f7c-8754-a4fcc06a18be
- The Node.js example turns off local evaluation, and both examples keep the SDK's error.
- The status examples handle the error first and explain retrying a not_found.
- The SDK section adds install, client reuse, timeouts and retries, uncertain outcomes, and a no-network testing example.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

Generated-By: PostHog Desktop
Task-Id: e72a2c10-f145-4f7c-8754-a4fcc06a18be
…m pages

The overview keeps the template, key, idempotency, errors, statuses and behavior. Node.js, Python and API pages hold the setup, send, status, retries and testing for each platform, with a sidebar group like push notifications.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

Generated-By: PostHog Desktop
Task-Id: e72a2c10-f145-4f7c-8754-a4fcc06a18be
…he overview

- A quick start links to the Node.js, Python and API pages.
- Creating an email follows the Transactional section's wizard, and the hidden workflow explanation is gone.
- Agents can set it up with the PostHog MCP server.
- Protect sensitive data turns off click tracking and keeps deliveries, opens and statuses.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

Generated-By: PostHog Desktop
Task-Id: e72a2c10-f145-4f7c-8754-a4fcc06a18be
Co-Authored-By: Claude Opus 5.5 <[email protected]>

Generated-By: PostHog Desktop
Task-Id: e72a2c10-f145-4f7c-8754-a4fcc06a18be
The send endpoint now takes a flat body (PostHog/posthog#126287), so the field table, the curl example and the engagement events note use template_id and distinct_id.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

Generated-By: PostHog Desktop
Task-Id: e72a2c10-f145-4f7c-8754-a4fcc06a18be
…il API page

Document the code form of the send route (template_key with the subject
and body), capture_engagement, the checks that only apply to it, and a
delivery_unavailable anchor for the 503's docs link.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…mits

Add DKIM-Signature and Received to the reserved headers, the header name
and value limits, the tag length, and that reply_to takes bare addresses.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…t template_key and ph_ tags do

- A retry with the same Idempotency-Key is the same request when every field of the code-form table matches.
- template_key groups the email's metrics and sends.
- A tag name that starts with ph_, in any case, is refused.
- capture_engagement on a template send defaults to the project's setting.
- Drop a double blank line after the checks table (fix-mdx).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ell out the ph_ case rule

- The template-send table gets a capture_engagement row with the project default, and says a retry sends the field the same way.
- The code-form row keeps only its own default.
- A ph_ tag name is refused in uppercase or lowercase.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@github-actions github-actions Bot added docs Improvements or additions to product documentation, "Docs" content PR only touches files under contents/ labels Nov 2, 2026
@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Deploy preview

Status Details Updated (UTC)
🟢 Ready View preview Nov 04, 2026 08:50AM

Changed pages

Page Source
Send transactional emails contents/docs/workflows/transactional-emails/index.mdx
Send transactional emails with the API contents/docs/workflows/transactional-emails/api.mdx
Send transactional emails with Node.js contents/docs/workflows/transactional-emails/node.mdx
Send transactional emails with Python contents/docs/workflows/transactional-emails/python.mdx

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

PostHog Review · Standard · reviewed 673346e: 1 Should fix.

This is the only automatic review while this pull request is a draft. Comment @posthog review to review new changes sooner. Otherwise, a review of the new changes runs when you mark it ready for review.

See this review and its findings in PostHog Review. Comment @posthog review --deep for a Deep review. A Deep review takes longer and looks deeper than a Standard review.

| `to` | Yes | The email address of the recipient. One address for each request. |
| `message_data` | No | An object with the values for the template's `{{ variables.* }}` placeholders. For example, `{"reset_link": "https://example.com/reset"}` fills `{{ variables.reset_link }}`. The template can't read person properties, so pass all the data that the email needs here. |
| `distinct_id` | No | The distinct ID of the recipient's person. PostHog records the send and its [engagement events](/docs/workflows/engagement-events) against this person. If you leave it out, PostHog uses the email address as the distinct ID, which might not match the person in your product. We recommend that you always send it. |
| `capture_engagement` | No | Whether PostHog captures the email's [engagement events](/docs/workflows/engagement-events). The default is the project's **Capture email engagement events** setting. Send `false` to turn capture off for this email. On a retry with the same `Idempotency-Key`, send this field as you did the first time, or PostHog returns `idempotency_key_reused`. |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 · Update the shared retry contract for capture_engagement

For template sends retried with changed capture_engagement, this row specifies idempotency_key_reused, but the linked overview in transactional-emails/index.mdx:101 defines identical requests using only template, to, distinct_id, and message_data. Readers following that retry contract encounter an unexpected 422; update the overview's definition in this change.

@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Bundle report

Total JS (gzip)

11.71 MiB (+0.1 KiB / +0.0%)

Eager graph (modules shipped in each entrypoint's initial chunks)

Entrypoint Eager size Budget Modules
✅ app 9.94 MiB (no change) report-only 1024
Largest modules in the app closure
Module Size
css ./node_modules/.pnpm/[email protected][email protected]/node_modules/css-loader/dist/cjs.js??ruleSet[1].rules[8].oneOf[1].use[1]!./node_modules/.pnpm/[email protected][email protected][email protected]/node_modules/postcss-loader/dist/cjs.js??ruleSet[1].rules[8].oneOf[1].use[2]!./src/styles/global.css 839.5 KiB
./src/components/Stickers/Stickers.tsx 696.4 KiB
./node_modules/.pnpm/@[email protected][email protected][email protected][email protected]/node_modules/@posthog/icons/dist/posthog-icons.es.js 354.8 KiB
./node_modules/.pnpm/[email protected]_@[email protected][email protected]/node_modules/react-markdown/lib/react-markdown.js + 88 modules 351.4 KiB
./node_modules/.pnpm/[email protected][email protected][email protected][email protected]/node_modules/framer-motion/dist/es/render/components/motion/proxy.mjs + 126 modules 287.2 KiB
./node_modules/.pnpm/[email protected][email protected]/node_modules/cloudinary-core/cloudinary-core.js 281.9 KiB
./src/components/SearchUI/index.tsx + 87 modules 273.1 KiB
./gatsby-browser.tsx + 59 modules 259.1 KiB
./node_modules/.pnpm/@[email protected][email protected]/node_modules/@posthog/brand/dist/generated/hoggies/svg/construction-1.mjs 248.5 KiB
./src/components/Pricing/PricingSlider/Slider.tsx + 87 modules 240.2 KiB
./node_modules/.pnpm/@[email protected][email protected]/node_modules/@posthog/brand/dist/generated/hoggies/svg/construction-2.mjs 228.2 KiB
./node_modules/.pnpm/@[email protected][email protected]/node_modules/@posthog/brand/dist/generated/hoggies/svg/magnifying-glass.mjs 221.7 KiB
./src/hooks/skills.tsx + 2 modules 150.7 KiB
./src/hooks/useProduct.ts + 9 modules 149.9 KiB
./src/components/Icons/Icons.tsx 147.4 KiB

Eager-graph budgets are report-only until a baseline is established. Sizes are gzip of public/**/*.js; eager size is webpack module source bytes for the modules actually shipped in the entrypoint's initial chunks (post-tree-shake).

Base automatically changed from posthog/docs-transactional-email-api to master November 2, 2026 12:35
@github-actions github-actions Bot removed the content PR only touches files under contents/ label Nov 2, 2026
Silthus and others added 2 commits November 2, 2026 15:00
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	contents/docs/workflows/transactional-emails/api.mdx
#	contents/docs/workflows/transactional-emails/node.mdx
#	contents/docs/workflows/transactional-emails/python.mdx
…code send

The first release sends content from code with template_key, subject, html and text only. React Email output is rendered by the app and sent as html.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@github-actions github-actions Bot added the content PR only touches files under contents/ label Nov 2, 2026
Silthus and others added 2 commits November 2, 2026 15:49
- Drop capture_engagement from the template send: the send route
  refuses it there, with the other fields of content from your code.
- Fold sender_not_allowed into sender_not_verified, the code the
  first release returns for a from address no verified sender can use.
- Add "Send content from your code" to the Node.js and Python pages,
  with a React Email example rendered by the app.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
… in a replay

- A field error is a 400 with type validation_error, a field code
  such as required or invalid, and attr naming the field.
- A replay also compares the sender PostHog chose, so a retry without
  from after the default sender changed returns idempotency_key_reused.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@Silthus Silthus changed the title docs(workflows): both send forms, idempotency and the send checks on the transactional email API page docs(workflows): send content from your code on the transactional email API, Node.js and Python pages Nov 2, 2026
…ntify distinct ID

Add "Link emails to your users" to the API, Node.js and Python pages: pass
the distinct ID you pass to identify(), what happens without one today (a
separate person keyed by the email address), and that invites and signup
checks can leave it out. The overview and the API tables point there
instead of "always send it".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The Node.js and Python client examples used <ph_project_api_key> and a
hard-coded ingestion host, which the site doesn't replace. They now use
<ph_project_token> and <ph_client_api_host>, as the SDK pages do, so a
logged-in reader sees their own project token and host.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

content PR only touches files under contents/ docs Improvements or additions to product documentation, "Docs"

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants