Skip to content

docs(workflows): both send forms, idempotency and the send checks on the transactional email API page - #1

Closed
Silthus wants to merge 6 commits into
base/transactional-email-docsfrom
docs/transactional-email-api-code-form
Closed

Silthus wants to merge 6 commits into
base/transactional-email-docsfrom
docs/transactional-email-api-code-form

Conversation

@Silthus

@Silthus Silthus commented Oct 22, 2026 •

Copy link
Copy Markdown
Owner

Executive summary

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 base/transactional-email-docs, which is the head of PostHog#21564. It changes one page: contents/docs/workflows/transactional-emails/api.mdx, +59/-1. Before, this PR rewrote our own copy of the page. PostHog#21564 now owns that page, so this PR keeps only the part that PostHog#21564 does not have. Silthus/posthog.com#2 duplicated PostHog#21564 and is closed.

Slice 13 of Silthus/posthog#461.

Impact

Pays into Grow Workflows through billed workflows_emails from API sends, read as Code-sending orgs and Switched orgs. An engineer who moves email off another provider sends from code, so this page is the first page they read. Expected move: small, up.

Note

🔵 Merge risk: low · two-way door

Changes

  • 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 PostHog/posthog.com#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).

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 idempotency fingerprint of the CDP service (transactionalSendFingerprint). 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_NAME pattern and length limit for every ASCII code point, the empty name, and 126- and 127-character names.
  • 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.
  • CI on 673346e75: Vale, markdownlint, spelling, semgrep, and the .md redirect check pass. auto-label fails on the fork (no docs label), and the preview build skips on forks.
  • Gaps:
    • I did not start the dev server or load the page. This box has no node_modules for posthog.com.
    • I did not run codespell or markdownlint locally. CI's Vale, markdownlint and spelling jobs run on the fork.
    • No full Gatsby build for this revision. The lane gate covers the page's markdown checks only.
    • CI on the fork: the auto-label job fails because the fork has no docs label.
    • No screenshots. The page has no new component, and the PR is not a visual change.

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)

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 PostHog#21564? Build on PostHog#21564 and keep only the code form, capture_engagement, its checks, and the delivery_unavailable anchor. Close #2. 🤖 Agent Silthus/posthog#461

Open: accept the live-run gap: no dev server or rendered page on this box. Recommendation: accept, and check the page in the Vercel preview of PostHog#21564's stack before the merge.

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; no rendered page.

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.

History: the earlier revision on our own page (before the rebase onto PostHog#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.

Impact

Pays into Grow Workflows through billed workflows_emails from API sends, read as Code-sending orgs and Switched orgs (not measured yet: the send attribution of PostHog/posthog#120628 plus its insight on a dedicated dashboard). In scope: Messaging, email. Expected move: small, up. Every docsUrl an SDK returns opens a section of this page.

[!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 PostHog/posthog.com#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 `jane@example.com`."
  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 PostHog#21306, instead of commits on PostHog#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

Silthus added a commit that referenced this pull request Oct 22, 2026
Silthus added a commit that referenced this pull request Oct 22, 2026
Silthus added a commit that referenced this pull request Oct 22, 2026
Silthus added a commit that referenced this pull request Oct 22, 2026
Silthus added a commit that referenced this pull request Oct 22, 2026
Silthus added a commit that referenced this pull request Oct 22, 2026
Silthus added a commit that referenced this pull request Oct 22, 2026
Silthus added a commit that referenced this pull request Oct 22, 2026
Silthus added a commit that referenced this pull request Oct 22, 2026
Silthus added a commit that referenced this pull request Oct 22, 2026
Silthus added a commit that referenced this pull request Oct 23, 2026
Silthus added a commit that referenced this pull request Oct 23, 2026
Silthus added a commit that referenced this pull request Oct 23, 2026
Silthus added a commit that referenced this pull request Oct 23, 2026
Silthus added a commit that referenced this pull request Oct 23, 2026
Silthus added a commit that referenced this pull request Oct 23, 2026
Silthus added a commit that referenced this pull request Oct 23, 2026
@Silthus
Silthus force-pushed the docs/transactional-email-api branch from 2b0a7a2 to 8ee6d24 Compare October 23, 2026 14:25
@Silthus
Silthus force-pushed the docs/transactional-email-api-code-form branch from 6c55cf4 to 641b3bd Compare October 23, 2026 14:25
…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 <noreply@anthropic.com>
@Silthus
Silthus force-pushed the docs/transactional-email-api-code-form branch from 641b3bd to 76ad37a Compare October 31, 2026 09:50
@Silthus
Silthus changed the base branch from docs/transactional-email-api to base/transactional-email-docs October 31, 2026 09:50
Silthus and others added 5 commits November 1, 2026 13:01
…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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…only

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
@Silthus

Silthus commented Nov 2, 2026

Copy link
Copy Markdown
Owner Author

Moved to PostHog#21819.

@Silthus Silthus closed this Nov 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant