Repository navigation
Spec: email send API #467
Description
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 newemail_send:writescope. The public project token never sends. frommust 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 }.urlopens the project's API sends workflow in Workflows, where each send shows in the logs by itsid. - Every rejection carries a stable
code, a message that says what happened, why, and how to fix it, and adocs_url, so an agent can repair the call without a human. posthog-nodenever 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
- 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.
- As an engineer using Node, I want
posthog.emails.send(...)on the client I already use, so that I add no new dependency. - 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.
- As a project admin, I want a dedicated
email_send:writescope, so that a sending key cannot edit workflows or read data. - 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.
- As an engineer, I want to name the sender in
from, so that each email goes out from the right address on my domain. - As an engineer, I want
fromto accept theName <address>form, so that I set the display name in the same field. - As an engineer, I want a send without
fromto use my project's default sender, so that simple calls stay short. - As an engineer, I want a clear error when
fromis on a domain the project has not added, so that I know to add the sender. - 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.
- As an engineer, I want a clear error when I omit
fromand the project has no default sender, so that I know both ways to fix it. - As an engineer, I want to send with
html,textor both, so that I control how each mail client shows the message. - 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.
- As an engineer, I want
subjectwith characters like&and<to arrive exactly as I wrote it, so that no escaping leaks into the inbox. - As an engineer, I want to set
replyTo,ccandbccas a string or a list, so that the call matches what I know from other email APIs. - As an engineer, I want a clear error when I name more than one
torecipient, so that I know to send one call per recipient. - 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.
- As an engineer, I want a clear error when the email has no body, so that an empty email never goes out.
- As an engineer, I want to pass an
Idempotency-Key, so that a retry after a timeout never sends the same email twice. - 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. - 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.
- 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.
- As an engineer, I want a
rate_limitederror withRetry-After, so that my code backs off for the right time. - As an engineer, I want a
send_service_unavailableerror that tells me a retry with the same key is safe, so that a PostHog outage costs me no duplicate emails. - As an engineer, I want a clear error when my project's email sending is paused, so that I know where to resume it.
- As an engineer, I want a clear error when my project is over its billing quota, so that I know why sends stop.
- 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.
- As an engineer, I want to pass
distinctId, so that the delivery events (sent, delivered, opened, clicked, bounced) land on that person. - As an engineer, I want a send without
distinctIdto still work, so that I can email someone who is not a person in PostHog yet. - As an engineer, I want the call to return fast without waiting for SES, so that my signup request stays quick.
- As an engineer, I want the response to carry the send
id, so that I can find that send later. - As an engineer, I want the response to carry a
urlto where my sends show in PostHog, so that I can check what happened to one. - As an engineer using Node, I want
{ data, error }instead of exceptions, so that one failed email never crashes my request handler. - As an engineer using Node, I want
errorto carry thecode,message,statusCode,docsUrlandfield, so that I can log and branch on it. - 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.
- 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. - 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. - As an engineer, I want a documented cURL example, so that I can send from any language.
- As an engineer, I want every error's
docs_urlto open the docs section for that code, so that the fix is one click away. - 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- As a coding agent, I want every example to show
fromon the user's own domain, so that I never copy a placeholder sender into their code. - 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.
- As a coding agent, I want the skill to tell me to pass
distinctIdwhen the user is known, so that delivery events land on the right person. - 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.
- 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.
- As a project member, I want to open
/workflows/api-sendsand land on the API sends workflow's invocations, so that I see every API send in one place. - 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.
- 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.
- As a project member, I want nobody to edit the API sends workflow's graph, so that API sends keep working.
- 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.
- 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.
- As a project member, I want suppressed and bounced recipients to stay protected for API sends, so that my sender reputation stays safe.
- As PostHog, I want API sends behind a backend flag that fails closed, so that we roll it out project by project.
- As PostHog, I want one email API send event captured server-side per accepted send, so that we can see adoption.
- 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/.@currentresolves to the key's own project; another project's id answers 403.- Auth:
ProjectSecretAPIKeyAuthenticationonly (personal API keys and session auth do not apply). New API scope objectemail_sendwith actionwrite, so the scope string isemail_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 answers403 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_limitedcarriesRetry-After. The team sending-tier cap still applies later in the email worker. - Idempotency: optional
Idempotency-Keyheader, 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
idandurl, 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.
- Same key, same body, accepted: returns the same
- 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 sendevent 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.
frompresent: 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).fromabsent: 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 verified422 sender_not_verified; nofromand no default sender422 no_default_sender.
The hidden API sends workflow
- One
HogFlowper team with a neworigin_productchoiceapi_sends("API sends") and a new trigger typeapi_send. Its graph: trigger → one transactional email step → exit. It billsfunction_email. get_or_create_api_sends_flow(team)creates it on the first accepted send. A partial unique index onteam_idwhereorigin_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_sendjoins the owned workflow types, so the Workflows list leaves the flow out the way it leaves broadcasts out.?type=api_sendlists 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 routePOST /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 whencheckHogFlowQuotaLimitssays the team is over quota, and 423 when the flow's email sending is paused. - Otherwise it builds a synthetic
$workflows_api_sendevent with the send's distinct id, setsvariables.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 tosending_paused, and every other non-200 and every timeout to503 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
distinctIdis set. - No status endpoint in this spec. The existing invocation results endpoint answers one send's run state with a
hog_flow:readkey.
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 lazyemailsgetter likemessagingin PostHog/posthog-js#5173. It reuses that PR's server-only pattern and its corerequestJsonhelper (timeouts and retries throughfetchWithRetry).- Auth:
Authorization: Bearer <secretKey>with the client's existingsecretKey. The body is JSON: a project secret key resolves@currentto its own project, so no project token rides along. options.idempotencyKeybecomes theIdempotency-Keyheader. 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 missingsecretKeyreturns an error with codemissing_secret_keybefore any request. A disabled client returns an error with codeclient_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_sendand 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>/withconfig.yamlanddescription.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:
- Detect the platform. Node uses
posthog-nodeemails.send; any other server language uses the HTTP API. Never browser code; the secret key stays on the server. - 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. - 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.
- Flag
workflows-email-domain-agent-setupoff (the sender tools are missing or refused): tell the person to add and verify the sender in the PostHog UI, and wait. - Flag
workflows-email-apioff (email_api_not_enabled): stop and tell the person the email API is not enabled for their project; do not retry. - Create a project secret API key with
email_send:write(the person does this in project settings) and read it from an environment variable. - Write the send:
fromexplicitly on the user's own verified domain,distinctIdwhen the user is known, an idempotency key derived from the business event. - Read
error.codeand follow the catalog's fix text; print the fixed/workflows/api-sendslink so the person can see the send.
- Detect the platform. Node uses
- Every example shows
fromexplicitly on the user's own domain. None uses a PostHog or placeholder sender.
Schema and data
- One new
origin_productchoice, 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
ccplusbcclimit.
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-nodecarries the samerequestJsonhelper 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
optionsargument, the way Resend does it, not a payload field. - The
ccplusbcclimit defaults to the provider's per-message recipient limit minus the oneto. - When
workflows-email-domain-agent-setupis off, the skill falls back to the PostHog UI for verification. posthog-noderetries a send only when it carries an idempotency key, and adds two SDK-only error codes:missing_secret_keyandclient_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:
- The public endpoint (Django
APIBaseTestwith 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. - The CDP
api_sendsroute (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. - The email step rendering (the existing hog flow executor tests): a flow built by
get_or_create_api_sends_flowplusvariables.emailrenders the right email queue params, raw characters intact. posthog-nodeemails.sendagainst a fake PostHog API. Prior art: the fake messaging preferences API in Filter for people with session recordings on persons modal PostHog/posthog#5173.- The redirect route through a kea logic test.
- 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
templatefield, 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-workflowsskill) 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 atemails.sendand the new skill instead of a hand-built workflow. Silthus/posthog#349 (messaging_preferencescope 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 patternemails.sendfollows, with one difference:emails.sendreturns{ data, error }, whilemessaging.setPreferencesrejects 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-apiflag 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-nodeemails.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
Silthus commentedon Oct 14, 2026
Closed on purpose: this is the reference document for the implementation tickets, kept off the frontier. Review happens on #464.