Repository navigation
Email API: CDP api_sends route and the Django client that calls it #470
Description
Map: Map: send email from code with one PostHog call
Spec: Spec: email send API
Cast: Opus 5.5, high
Repository: PostHog/posthog (Django and nodejs/)
Question
Can Django queue one email on the API sends workflow through a scoped service token, with an id it chose, and the email arrive?
Delivers end to end. Django's create_hog_flow_api_send(team_id, hog_flow_id, send) mints a token for a new purpose (audience posthog:workflows:api_send, its own signing secrets setting, pinned to team and flow, 2 minute lifetime) and posts an ApiSendInvocation to the new CDP route POST /api/projects/:team_id/hog_flows/:id/api_sends. The route verifies the token, answers 404 unless the flow is the team's API sends flow, 409 unless active, 402 when checkHogFlowQuotaLimits refuses, 423 when the flow's email sending is paused. Otherwise it builds a synthetic $workflows_api_send event with the send's distinct id (or a synthetic one), sets variables.email, creates one invocation with the given id and queues it; a duplicate id answers 200 without a second job. The route loads a flow it has not cached yet. The hog flow schema accepts the api_send trigger type, and the email queue schema coerces from.integrationId to a number. Spec sections: "Django → CDP", "The hidden API sends workflow".
Test seams.
- The CDP route, through the CDP API tests with a real queue: valid token queues one invocation with the given id; replay of the id queues nothing new; wrong audience, team or flow, inactive, quota-limited and paused flows each answer their status and queue nothing.
- The email step rendering, through the existing hog flow executor tests: a flow shaped by
get_or_create_api_sends_flowplusvariables.emailrenders the right email queue params, withTom & Jerryand<intact and a numericintegrationId. - The Django client: the token it sends verifies for that team and flow only, and a timeout surfaces as a distinct error the endpoint ticket can map.
Write scope. posthog/jwt.py (audience), products/workflows/backend/service_jwt.py (purpose), the signing secrets setting next to WORKFLOWS_RESCHEDULE_JWT_SECRETS in posthog/settings/data_stores.py with a local dev default, posthog/plugins/plugin_server_api.py, nodejs/src/cdp/cdp-api.ts, nodejs/src/common/api/middleware/internal-api-auth.ts (exemption), nodejs/src/cdp/config.ts, nodejs/src/cdp/schema/hogflow.ts (trigger type), nodejs/src/cdp/schema/cyclotron.ts (coercion), their tests.
Proof.
- Red: a route test posts a signed request and gets 404 (no route).
- Green: the route queues exactly one invocation with the id from the body.
- Local: with the dev stack up, from a Django shell create the flow and call
create_hog_flow_api_sendwith a verified dev sender; the email shows in maildev, and the flow's logs show the send id.
Acceptance criteria
- No new shared internal secret caller; the route verifies its own token.
- A token for one flow cannot start another flow, and a token for another audience is refused.
- One request queues one invocation with the caller's id; a replay queues nothing new and answers 200.
- Inactive, quota-limited and paused flows answer 409, 402 and 423 and queue nothing.
- The rendered email keeps raw characters and a numeric
integrationId; suppression and bounce prevention still run. - Local proof: an email lands in maildev.
How to build (map #461)
/tddred-green-refactor, outside-in, at the seams named above. Real Postgres, Valkey and ClickHouse; mock only what the seam section allows.- Repo skills apply where the diff touches their area:
/writing-tests,/improving-drf-endpoints,/django-migrations,/adding-api-scopes,/writing-ui-components,/writing-kea-logics,/writing-user-facing-copy,/writing-code-comments. - Public repo: no usage numbers, customer names, internal metrics, Slack content or security reasoning in code, commits or the PR. Fixture addresses use
example.com. The private note the map points at is background only; never quote it. - Shipped means a draft PR handed off for review (see the map's Notes for remotes and branches). Never mark ready, enqueue or merge.
Activity
Silthus commentedon Oct 14, 2026
Runtime: Claude Code background Agent
Worker: implement-470-cdp-api-sends-route
Cast: Opus 5.5, high. Wall-time cap: 150 minutes.
Lands on integration branch
spec/email-send-api, in parallel with the other open ticket on it (scopes do not overlap).Silthus commentedon Oct 14, 2026
Claimed by conductor dispatch. Runtime: Claude Code background Agent, worker implement-470-cdp-api-sends-route, Opus 5.5 high. Integration branch: spec/email-send-api.
Silthus commentedon Oct 14, 2026
Landed on
spec/email-send-apias a fast-forward from64ab79c5a68.Landed SHA:
ca53fe7972d(feature commit19c94d45e45, merge of the integration tip0a4852c7c55, snapshot commitca53fe7972d)What landed
posthog/plugins/plugin_server_api.py):create_hog_flow_api_send(team_id, hog_flow_id, send: ApiSendInvocation). It mints a token on the new purposeWORKFLOWS_API_SEND_JWT_PURPOSE(audienceposthog:workflows:api_send, keyWORKFLOWS_API_SEND_JWT_SECRETS, claimsteam_idandhog_flow_id, 2 minute lifetime). It postsasdict(send)with a 5 second timeout. Arequests.TimeoutraisesHogFlowApiSendTimeout. An unprovisioned key raisesRuntimeErrorbefore any request.ApiSendInvocationandApiSendEmailare@frozendataclasses next to the client.ApiSendEmailfields:to, integration_id, from_email, from_name, reply_to, cc, bcc, subject, text, html. All are strings exceptintegration_id, and the endpoint ticket passes""for empty ones.POST /api/projects/:team_id/hog_flows/:id/api_sends(nodejs/src/cdp/cdp-api.ts, body schema and invocation builder innodejs/src/cdp/services/hogflows/api-send.ts). The checks run in this order: 503 if the key is unset, 401 for a bad token or claims, 400 for a bad body, 404 when the team is missing or the flow is not the team'sorigin_product = api_sendsflow, 409 unless active, 402quota_limitedfromcheckHogFlowQuotaLimits, 423sending_paused. If every check passes, it builds a$workflows_api_sendevent with the caller's distinct id, orapi-send-<invocation id>without one. It setsvariables.emailand queues one invocation with Django's id. A replayed id hits the job primary key (23505), and the route answers200 {status: "queued", invocation_id}without a second job.internal-api-auth.ts. No newINTERNAL_API_SECRETcaller.HogFlowManagerService.getHogFlowReadingPrimaryOnMiss. On a cache miss, or when the cache has stored the flow's absence, it reads the primary database instead of the read replica.api_sendjoins the Node trigger union and the DjangoTRIGGER_TYPESmirror.origin_productis loaded with the flow. The email queue schema turns a numeric stringintegrationIdinto a number.Proof
404(no route), 13 failed. The executor rendering test stayed on queuehogflow, because the stringintegrationIdfailed the queue schema. The Django test failed to importWORKFLOWS_API_SEND_JWT_PURPOSE.cdp-api.serial.test.ts, "hogflow api sends"). The executor test renders the queue params withTom & Jerry,<hi>and a numericintegrationId. The 2 Django client tests pass.cdp-api.serial.test.tspassed (116).src/cdp/services/hogflows,src/common/api/middleware,src/cdp/schemaand the scoped JWT tests passed (427). The pre-pushhogli ci:preflight --strictreported 0 failures.tsgo --noEmitreports nothing in the changed files. Its other errors are in session replay and ingestion docs files that this ticket does not touch.get_or_create_api_sends_flow(team 2), thencreate_hog_flow_api_sendwith a verified maildev sender onexample.com:200 {'status': 'queued', 'invocation_id': '01a13a9f-818f-0000-6a0f-fa7d633bc7e4'}, replay200with the same id and no second job (the worker logged the primary key conflict)Tom & Jerry say <hi> (ticket 470 proof), tojane@example.com, fromTom & Jerry <hello@example.com>01a13a9f-…:Starting workflow execution at trigger on [Event:…|$workflows_api_send|…],Email sent to jane@example.com from Tom & Jerry <hello@example.com>,Workflow completedTest seams
CyclotronJobQueuePostgresV2. Only the quota check is a spy, onisTeamQuotaLimited.template-emailand the graphget_or_create_api_sends_flowwrites.internal_requests.postpatched at the HTTP boundary.Calls I made
posthog/plugins/plugin_server_api.pynext to the other workflow CDP purposes, not inproducts/workflows/backend/service_jwt.py. The client inposthog/would otherwise import fromproducts/.TRIGGER_TYPES: I added only the mirror entry. The serializer still refusesapi_sendas a trigger on user writes, which is right: onlyget_or_create_api_sends_flowwrites it.Follow-ups
WORKFLOWS_API_SEND_JWT_SECRET(DjangoWORKFLOWS_API_SEND_JWT_SECRETS, NodeWORKFLOWS_API_SEND_JWT_SECRET, same value, comma-separated, newest first) needs a value everywhere before the flag turns on. Without it, Django raises before the call and the route answers 503.402toquota_limit_exceeded,423tosending_paused, and every other non-200,HogFlowApiSendTimeoutandrequests.ConnectionErrortosend_service_unavailable.api_send.docker compose … up personhog-replica personhog-router, the replica points at theposthogdatabase, where the person tables do not exist. I ran the replica againstposthog_personsfor this proof and removed both containers afterwards. One earlier proof job (01a13a94-…, queued while personhog was down) stays queued in the dev cyclotron database. It sends one more maildev email the next time the dev workers run.