Skip to content

Spec: email send API #467

Closed
Closed

Description

@Silthus

Map: Map: send email from code with one PostHog call
Answers: Spec the email send API and slice it into PRs
Built on: the accepted contract in #463 (architecture brief on branch prototype/email-api-send-contract, sections 4 to 9), the sender tool inventory in #462, the upstreaming task #466, and the charting decisions on the map.

This issue is the reference document for the implementation tickets. It is closed on purpose so it stays off the frontier.

Problem Statement

An engineer, or the coding agent working for them, wants to send one transactional email from code: a welcome email, a signup confirmation, a password reset.
Services like Resend take one call and one key.
PostHog already runs the whole sending path (SES, queues, retries, suppression, delivery events), but there is no single call into it.
Today it takes a workflow, a trigger and several API calls, and agents never suggest PostHog for the job because nothing tells them it can.

Solution

Behind the backend flag workflows-email-api, a project can send one email with one call:

const posthog = new PostHog('phc_...', { secretKey: process.env.POSTHOG_SECRET_KEY })

const { data, error } = await posthog.emails.send({
  from: 'Acme <hello@acme.com>',
  to: 'jane@example.com',
  subject: 'Welcome to Acme',
  html: '<p>Hi Jane, welcome aboard.</p>',
  distinctId: 'user_42',
})
curl -X POST https://us.posthog.com/api/projects/@current/emails/ \
  -H "Authorization: Bearer $POSTHOG_SECRET_KEY" \
  -H "Idempotency-Key: welcome-user_42" \
  -H "Content-Type: application/json" \
  -d '{"from":"Acme <hello@acme.com>","to":"jane@example.com","subject":"Welcome to Acme","html":"<p>Hi Jane</p>"}'
  • The key is a project secret API key (phs_) with the new email_send:write scope. The public project token never sends.
  • from must be a verified sender on the project's own domain. When it is absent, the project's default sender is used.
  • Every API send is transactional: fast lane, no opt-out check, no unsubscribe headers. Suppression still applies.
  • The call returns { id, url }. url opens the project's API sends workflow in Workflows, where each send shows in the logs by its id.
  • Every rejection carries a stable code, a message that says what happened, why, and how to fix it, and a docs_url, so an agent can repair the call without a human.
  • posthog-node never throws on API errors: it returns { data, error }.
  • One agent skill, next to PostHog's instrument-* skills and served through the PostHog MCP, teaches an agent to verify a sender through the PostHog MCP first, then wire the first send into the user's code.

Under the hood each API send is one invocation on the team's one hidden "API sends" workflow, so billing, quota, pausing, metrics and logs work the way they do for every other workflow.

User Stories

  1. As an engineer, I want to send a transactional email with one HTTP call, so that I do not have to build a workflow first.
  2. As an engineer using Node, I want posthog.emails.send(...) on the client I already use, so that I add no new dependency.
  3. As an engineer, I want to authenticate with a project secret API key, so that sending keeps working when the person who created the key leaves.
  4. As a project admin, I want a dedicated email_send:write scope, so that a sending key cannot edit workflows or read data.
  5. As a project admin, I want the public project token to never send email, so that nobody can send from my domain with a key that ships in browsers.
  6. As an engineer, I want to name the sender in from, so that each email goes out from the right address on my domain.
  7. As an engineer, I want from to accept the Name <address> form, so that I set the display name in the same field.
  8. As an engineer, I want a send without from to use my project's default sender, so that simple calls stay short.
  9. As an engineer, I want a clear error when from is on a domain the project has not added, so that I know to add the sender.
  10. As an engineer, I want a clear error when the sender's domain is not verified yet, so that I know to finish DNS verification.
  11. As an engineer, I want a clear error when I omit from and the project has no default sender, so that I know both ways to fix it.
  12. As an engineer, I want to send with html, text or both, so that I control how each mail client shows the message.
  13. As an engineer, I want a text-only send to still arrive as a readable HTML email, so that I do not have to write HTML for a plain message.
  14. As an engineer, I want subject with characters like & and < to arrive exactly as I wrote it, so that no escaping leaks into the inbox.
  15. As an engineer, I want to set replyTo, cc and bcc as a string or a list, so that the call matches what I know from other email APIs.
  16. As an engineer, I want a clear error when I name more than one to recipient, so that I know to send one call per recipient.
  17. As an engineer, I want a clear error that names the bad field when an address is malformed, so that I fix the right field.
  18. As an engineer, I want a clear error when the email has no body, so that an empty email never goes out.
  19. As an engineer, I want to pass an Idempotency-Key, so that a retry after a timeout never sends the same email twice.
  20. As an engineer, I want a replay with the same key and body to return the same id, so that my retry logic stays simple.
  21. As an engineer, I want a clear error when I reuse a key for a different email, so that a key collision never drops an email silently.
  22. As an engineer, I want a clear error when a send with my key is still in flight, so that I know to wait and retry.
  23. As an engineer, I want a rate_limited error with Retry-After, so that my code backs off for the right time.
  24. As an engineer, I want a send_service_unavailable error that tells me a retry with the same key is safe, so that a PostHog outage costs me no duplicate emails.
  25. As an engineer, I want a clear error when my project's email sending is paused, so that I know where to resume it.
  26. As an engineer, I want a clear error when my project is over its billing quota, so that I know why sends stop.
  27. As an engineer whose project does not have the feature yet, I want a clear "not enabled for this project" error, so that I do not debug my own code.
  28. As an engineer, I want to pass distinctId, so that the delivery events (sent, delivered, opened, clicked, bounced) land on that person.
  29. As an engineer, I want a send without distinctId to still work, so that I can email someone who is not a person in PostHog yet.
  30. As an engineer, I want the call to return fast without waiting for SES, so that my signup request stays quick.
  31. As an engineer, I want the response to carry the send id, so that I can find that send later.
  32. As an engineer, I want the response to carry a url to where my sends show in PostHog, so that I can check what happened to one.
  33. As an engineer using Node, I want { data, error } instead of exceptions, so that one failed email never crashes my request handler.
  34. As an engineer using Node, I want error to carry the code, message, statusCode, docsUrl and field, so that I can log and branch on it.
  35. As an engineer using Node, I want to pass an idempotency key as an option, so that the SDK sends it as the header for me.
  36. As an engineer using Node, I want a clear error before any request when the client has no secretKey, so that I see the setup mistake at once.
  37. As an engineer using Node, I want network failures to come back as an error, not an exception, so that my handling stays in one place.
  38. As an engineer, I want a documented cURL example, so that I can send from any language.
  39. As an engineer, I want every error's docs_url to open the docs section for that code, so that the fix is one click away.
  40. As a coding agent, I want each error to say what happened, why, and how to fix it, so that I can repair the call without asking a human.
  41. As a coding agent, I want an agent skill that triggers on "send an email", "welcome email", "password reset" and "transactional email", so that I find PostHog when a user asks for that.
  42. As a coding agent, I want the skill to tell me to verify a sender through the PostHog MCP before the first send, so that the first send does not fail.
  43. As a coding agent, I want the skill to tell me how to hand the person a Domain Connect link or DNS records, so that I can finish verification even though PostHog never writes DNS.
  44. As a coding agent, I want the skill to tell me what to do when the sender tools are not enabled for the project, so that I fall back to the PostHog UI instead of stalling.
  45. As a coding agent, I want the skill to tell me what to do when the email API is not enabled for the project, so that I stop and tell the person instead of retrying.
  46. As a coding agent, I want every example to show from on the user's own domain, so that I never copy a placeholder sender into their code.
  47. As a coding agent, I want the skill to tell me to keep the secret key on the server, so that I never put it in browser code.
  48. As a coding agent, I want the skill to tell me to pass distinctId when the user is known, so that delivery events land on the right person.
  49. As a coding agent, I want the skill to tell me to use an idempotency key derived from the business event, so that retries stay safe.
  50. As a coding agent, I want the skill to give me the fixed link to the API sends page, so that I can show the person where their sends appear.
  51. As a project member, I want to open /workflows/api-sends and land on the API sends workflow's invocations, so that I see every API send in one place.
  52. As a project member whose project never sent through the API, I want that link to show a short empty state with a docs link, so that I understand why there is nothing yet.
  53. As a project member, I want the API sends workflow to stay out of my Workflows list, so that it does not clutter the workflows I built.
  54. As a project member, I want nobody to edit the API sends workflow's graph, so that API sends keep working.
  55. As a project member, I want to pause email sending on the API sends workflow, so that I can stop API sends the same way I stop any workflow.
  56. As a project member, I want API sends to count toward billing, quota and the sending tier like every other email, so that nothing about cost is new.
  57. As a project member, I want suppressed and bounced recipients to stay protected for API sends, so that my sender reputation stays safe.
  58. As PostHog, I want API sends behind a backend flag that fails closed, so that we roll it out project by project.
  59. As PostHog, I want one email API send event captured server-side per accepted send, so that we can see adoption.
  60. As PostHog, I want the Django to CDP call to use a scoped service token, so that we add no new shared-secret caller.

Implementation Decisions

Endpoint

  • POST /api/projects/@current/emails/, also reachable as /api/projects/{project_id}/emails/. @current resolves to the key's own project; another project's id answers 403.
  • Auth: ProjectSecretAPIKeyAuthentication only (personal API keys and session auth do not apply). New API scope object email_send with action write, so the scope string is email_send:write. It joins the project secret API key allowlist in Python and its frontend twin, which the existing scope sync test keeps equal, and the project secret key settings offer it.
  • Flag: workflows-email-api, evaluated on the team uuid with organization and project groups, failing closed on any evaluation error. Off answers 403 email_api_not_enabled. The flag is backend only; the SDK and the skill have no flag of their own.
  • Throttles: the project-secret-key-aware throttle pair, per key and per team, each with a burst and a sustained rate kept as settings sized for email. 429 rate_limited carries Retry-After. The team sending-tier cap still applies later in the email worker.
  • Idempotency: optional Idempotency-Key header, at most 256 characters. A cache reservation keyed by team and key holds {request_hash, send_id, state} for 24 hours.
    • Same key, same body, accepted: returns the same id and url, enqueues nothing.
    • Same key, different body: 422 idempotency_key_reused.
    • Same key, still in flight: 409 idempotency_key_in_progress.
    • A timeout or unknown failure on the CDP call keeps the reservation, so a retry reuses the same send id. A definite rejection releases it.
  • The view stays thin: it checks the flag, validates the payload and calls one service function, api_send_service.send(team, payload, idempotency_key). All logic sits in the service.
  • Analytics: one server-side email api send event per accepted send, with the auth method recorded.

Payload

Field Type Rule
from string, optional address or Name <address>. Resolved to a verified email sender of the team. Absent means the default sender.
to string, or an array with one string Exactly one recipient. More answers too_many_recipients.
subject string Required, one line.
html string At least one of html and text.
text string Text only means Django derives html as the escaped text with line breaks.
replyTo string or string[] Each address checked.
cc, bcc string or string[] Each address checked. Their sum has a settings limit, and more answers too_many_recipients.
distinctId string, optional Becomes the distinct id of the send. Absent means a synthetic distinct id, and the team's engagement-events toggle is unchanged.

The shape leaves room for a later template field. It is not part of this spec.

Sender resolution

  • Runs in Django. The CDP side has no fallback.
  • from present: parse it; pick the team's email integration whose address equals it, else one on the same domain (the address becomes an override on that integration's domain, which the email worker already honors).
  • from absent: the team's default sender from the team's Workflows config.
  • The chosen sender must be verified.
  • Failures: unknown domain 422 sender_not_found; sender not verified 422 sender_not_verified; no from and no default sender 422 no_default_sender.

The hidden API sends workflow

  • One HogFlow per team with a new origin_product choice api_sends ("API sends") and a new trigger type api_send. Its graph: trigger → one transactional email step → exit. It bills function_email.
  • get_or_create_api_sends_flow(team) creates it on the first accepted send. A partial unique index on team_id where origin_product = 'api_sends' keeps it single, and concurrent first sends converge on one row. The graph is owned by code: on a version mismatch the function rewrites it, so no data migration is ever needed for a graph change.
  • api_send joins the owned workflow types, so the Workflows list leaves the flow out the way it leaves broadcasts out. ?type=api_send lists it.
  • The update path refuses edits to an API sends flow's graph and trigger. Pausing email sending on it works as on any flow.
  • The email step uses the existing email template. Its config sets the message category type to transactional. Its inputs read every field from variables.email.* with Liquid | raw, because Liquid escapes by default:
to.email          {{ variables.email.to | raw }}
from.integrationId {{ variables.email.integration_id }}   (the queue schema coerces it to a number)
from.email        {{ variables.email.from_email | raw }}
from.name         {{ variables.email.from_name | raw }}
replyTo, cc, bcc, subject, text, html   {{ variables.email.<field> | raw }}

(Trimmed from the #463 prototype brief, section 7.)

Django → CDP

  • New Django client function create_hog_flow_api_send(team_id, hog_flow_id, send) posts to a new CDP route POST /api/projects/:team_id/hog_flows/:id/api_sends.
  • Auth: a scoped service JWT with its own signing secrets setting and the audience posthog:workflows:api_send, pinned to the team and the flow, with a 2 minute lifetime. The route joins the exemption list of the shared internal secret middleware and verifies the token itself, like the reschedule and step resume routes. No new shared internal secret caller.
  • Body (from the prototype brief):
@frozen
class ApiSendInvocation:
    invocation_id: str     # UUIDT minted by Django; also the returned send id
    distinct_id: str | None
    email: ApiSendEmail    # resolved sender and validated fields, the variables.email shape above
  • The route loads the team and the flow, and answers 404 unless the flow belongs to the team and has origin_product = "api_sends", 409 unless it is active, 402 when checkHogFlowQuotaLimits says the team is over quota, and 423 when the flow's email sending is paused.
  • Otherwise it builds a synthetic $workflows_api_send event with the send's distinct id, sets variables.email, creates one invocation with the id Django minted and queues it. A duplicate id means "already queued" and answers 200.
  • The CDP flow manager must load a flow it has not cached yet, because the first send creates the flow moments before the call.
  • Django maps 402 to quota_limit_exceeded, 423 to sending_paused, and every other non-200 and every timeout to 503 send_service_unavailable.

Response

200 { "id": "<send id = invocation id>",
      "url": "<site url>/project/<project id>/workflows/<API sends flow id>/invocations" }

4xx/5xx { "type": "validation_error" | "authentication_error" | "throttled_error" | "server_error",
          "code": "<catalog code>",
          "detail": "What happened. Why. How to fix it.",
          "attr": "<field>" | null,
          "docs_url": "https://posthog.com/docs/workflows/email-api#<code>" }
  • Accepted means the invocation is queued. Everything after that (suppression, bounce prevention, provider errors) is asynchronous and shows in the API sends workflow's logs, keyed by the send id, and in the delivery events when distinctId is set.
  • No status endpoint in this spec. The existing invocation results endpoint answers one send's run state with a hog_flow:read key.

Error catalog

One module owns every code, its status, its message and its docs anchor. The view and the service raise EmailSendError(code, attr=...), and the platform exception handler renders the shape above plus docs_url. Messages follow /writing-user-facing-copy when they land; the wording below is the contract for meaning, not final copy.

Code Status Raised by What · why · fix
email_api_not_enabled 403 flag check Sending email through the API is not enabled for this project yet. It is in beta. Ask PostHog support to enable it.
authentication_failed 401 platform auth The key is missing or not a project secret API key. Use a key that starts with phs_.
permission_denied 403 platform scope check The key lacks the email_send:write scope. Add it in project settings.
invalid_request 400 payload validation A field is missing or has the wrong type (attr names it). Fix the field and retry.
missing_body 400 payload validation The email has no content. Send html, text or both.
invalid_address 400 payload validation An address in attr is not valid. Use name@example.com or Name <name@example.com>.
too_many_recipients 400 payload validation Too many recipients for one send. Send one call per to recipient and keep cc plus bcc under the limit.
sender_not_found 422 sender resolution No sender in this project matches the from domain. Add and verify a sender for that domain, or drop from to use the default sender.
sender_not_verified 422 sender resolution The sender exists but its DNS records are not verified, so it cannot send. Finish domain verification, then retry.
no_default_sender 422 sender resolution from is empty and the project has no default sender. Pass from on a verified domain, or set a default sender.
sending_paused 423 CDP route Email sending is paused for API sends in this project. Open the API sends page and resume it.
quota_limit_exceeded 402 CDP route The project is over its billing quota for workflows. Raise the limit in billing settings.
idempotency_key_in_progress 409 idempotency A send with this key is still being accepted. Wait a second and retry with the same key.
idempotency_key_reused 422 idempotency This key was used in the last 24 hours for a different email. Use a new key for a new email.
rate_limited 429 throttles Too many sends in a short time. Wait Retry-After seconds, then retry with the same key.
send_service_unavailable 503 CDP call PostHog could not queue the email right now. Retry with the same key; the email is sent at most once.

posthog-node: emails.send

  • posthog.emails.send(email, options?) on the Node client only, a lazy emails getter like messaging in PostHog/posthog-js#5173. It reuses that PR's server-only pattern and its core requestJson helper (timeouts and retries through fetchWithRetry).
  • Auth: Authorization: Bearer <secretKey> with the client's existing secretKey. The body is JSON: a project secret key resolves @current to its own project, so no project token rides along.
  • options.idempotencyKey becomes the Idempotency-Key header. The SDK retries a request only with an idempotency key present; without one it does not retry a POST.
  • It never throws on API or network errors. It returns { data: { id, url }, error: null } or { data: null, error: { code, message, statusCode, docsUrl, field } }. A missing secretKey returns an error with code missing_secret_key before any request. A disabled client returns an error with code client_disabled.
  • Types: SendEmailRequest, SendEmailResponse, SendEmailError, exported. Field names follow the HTTP API (replyTo, distinctId).

API sends redirect route

  • A fixed frontend route /project/{id}/workflows/api-sends, registered in the Workflows manifest. It lists flows with ?type=api_send and replaces the URL with the flow's Invocations tab.
  • With no flow yet it shows "No emails sent through the API yet" with a link to the docs. Opening it never creates the flow.
  • No other Workflows UI change: no list row, no tab.

Agent skill

  • Lives in PostHog/context-mill next to the instrument-* skills (context/skills/omnibus/<name>/ with config.yaml and description.md), is built into its release, and reaches agents through the PostHog MCP the same way. Its name joins the list of context-mill skill names in PostHog/posthog's bundled skills check, so no product skill can shadow it.
  • Triggers on: send an email, welcome email, password reset, signup confirmation, transactional email, email from code.
  • Steps it gives the agent, in order:
    1. Detect the platform. Node uses posthog-node emails.send; any other server language uses the HTTP API. Never browser code; the secret key stays on the server.
    2. Check for a verified sender before the first send, through the PostHog MCP sender tools that #466 brings upstream (list senders, read verification status). If one exists, use it as from.
    3. If none exists, set one up through the same tools: create the sender on the user's domain, read the DNS records, check Domain Connect and hand the person the approval link, or give them the records to add, then confirm verification. PostHog never writes DNS itself.
    4. Flag workflows-email-domain-agent-setup off (the sender tools are missing or refused): tell the person to add and verify the sender in the PostHog UI, and wait.
    5. Flag workflows-email-api off (email_api_not_enabled): stop and tell the person the email API is not enabled for their project; do not retry.
    6. Create a project secret API key with email_send:write (the person does this in project settings) and read it from an environment variable.
    7. Write the send: from explicitly on the user's own verified domain, distinctId when the user is known, an idempotency key derived from the business event.
    8. Read error.code and follow the catalog's fix text; print the fixed /workflows/api-sends link so the person can see the send.
  • Every example shows from explicitly on the user's own domain. None uses a PostHog or placeholder sender.

Schema and data

  • One new origin_product choice, no column change. One partial unique index on the hog flow table, built concurrently, in its own migration PR.
  • No new table. Idempotency lives in the cache; status lives in the existing invocation results, workflow logs and events.
  • Settings: the JWT signing secrets, the four throttle rates, the cc plus bcc limit.

Decisions this spec made on its own

Michael accepted all of them on 2026-10-14 (cards 2 to 8 on #464).

  • The skill lives in PostHog/context-mill and is named send-transactional-email.
  • The docs anchors point at one posthog.com reference page, /docs/workflows/email-api, shipped as its own ticket. The copyable agent prompt and the llms.txt line stay deferred, as the map says.
  • posthog-node carries the same requestJson helper as Filter for people with session recordings on persons modal PostHog/posthog#5173; whichever of the two PRs merges second drops its copy.
  • Idempotency in the SDK is a second options argument, the way Resend does it, not a payload field.
  • The cc plus bcc limit defaults to the provider's per-message recipient limit minus the one to.
  • When workflows-email-domain-agent-setup is off, the skill falls back to the PostHog UI for verification.
  • posthog-node retries a send only when it carries an idempotency key, and adds two SDK-only error codes: missing_secret_key and client_disabled.

Testing Decisions

A good test here calls a public seam and asserts what a caller sees: a status, a code, a queued invocation, an email in the dev mail catcher. It never asserts how a module did it. Each ticket names one or two seams and proves its slice there; it adds no test that an existing one already covers (/writing-tests).

Seams, highest first:

  1. The public endpoint (Django APIBaseTest with a real project secret key). Covers auth, scope, flag, payload validation, sender resolution, idempotency, throttles and the error catalog. The CDP call is the only mock, at the HTTP boundary. Prior art: the loops trigger view tests and the project secret key scope tests.
  2. The CDP api_sends route (Node API tests against a real queue). Covers token checks, flow ownership, active, quota, pause, the caller-minted id and duplicate handling. Prior art: the reschedule and step resume route tests in the CDP API tests.
  3. The email step rendering (the existing hog flow executor tests): a flow built by get_or_create_api_sends_flow plus variables.email renders the right email queue params, raw characters intact.
  4. posthog-node emails.send against a fake PostHog API. Prior art: the fake messaging preferences API in Filter for people with session recordings on persons modal PostHog/posthog#5173.
  5. The redirect route through a kea logic test.
  6. Local proof for every ticket that sends: a scratch Node project, or cURL, against the local dev stack, and the email lands in maildev. Not an automated test.

Out of Scope

  • The trial sender (onboarding@trial.posthog.com) and the sandbox recipient rule.
  • Templates defined in code, a template field, and template upserts.
  • An MCP send tool and a client plugin.
  • The copyable agent prompt on the docs page, the llms.txt line, and an in-app onboarding page.
  • A status endpoint for a send (GET /emails/{id}).
  • Batch sends, scheduled sends and outbound delivery webhooks.
  • SDKs other than Node, and any browser SDK.
  • Marketing email through the API.
  • Changes to sending tiers.
  • The billing quota check for scheduled and other non-event workflow runs: it stays a follow-up of Shape how an API send becomes an invocation on the hidden API sends workflow #463.
  • A Workflows UI entry for API sends beyond the redirect route.

Further Notes

  • Overlapping fork drafts. Silthus/posthog#350 (transactional email recipe in the building-workflows skill) teaches agents to build a workflow and trigger it from a backend. This spec replaces that path for one-off transactional email: when chore(workflows): document backend transactional email recipe #350 moves upstream, its recipe should point at emails.send and the new skill instead of a hand-built workflow. Silthus/posthog#349 (messaging_preference scope for project secret keys) is independent: a separate scope on a separate endpoint, and the two scopes sit side by side in the key picker. PostHog/posthog-js#5173 is the pattern emails.send follows, with one difference: emails.send returns { data, error }, while messaging.setPreferences rejects loudly.
  • Outside the code. Before the flag turns on for any project, the new JWT signing secrets setting needs a value in every environment, and the workflows-email-api flag needs to exist. The conductor and Michael own both.
  • Merge-risk danger areas. Auth and API scopes (new scope, project secret key allowlist), service-to-service auth (new token purpose and middleware exemption), migrations (partial unique index), real sends to customers and billing quota (the CDP route), concurrency (idempotency reservation, single-flow convergence), and public API contracts (the HTTP endpoint and posthog-node emails.send, a one-way door once released).
  • Private context. The research note the map points at holds the background. Tickets may point at it, never quote it.

Activity

  1. Silthus commented on Oct 14, 2026

    @Silthus
    OwnerAuthor

    Closed on purpose: this is the reference document for the implementation tickets, kept off the frontier. Review happens on #464.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions