Skip to content

Email API: CDP api_sends route and the Django client that calls it #470

Closed

Description

@Silthus

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_flow plus variables.email renders the right email queue params, with Tom & Jerry and < intact and a numeric integrationId.
  • 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_send with 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)

  • /tdd red-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

  1. self-assigned this
    on Oct 14, 2026
  2. Silthus commented on Oct 14, 2026

    @Silthus
    OwnerAuthor

    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).

  3. Silthus commented on Oct 14, 2026

    @Silthus
    OwnerAuthor

    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.

  4. Silthus commented on Oct 14, 2026

    @Silthus
    OwnerAuthor

    Landed on spec/email-send-api as a fast-forward from 64ab79c5a68.

    Landed SHA: ca53fe7972d (feature commit 19c94d45e45, merge of the integration tip 0a4852c7c55, snapshot commit ca53fe7972d)

    What landed

    • Django client (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 purpose WORKFLOWS_API_SEND_JWT_PURPOSE (audience posthog:workflows:api_send, key WORKFLOWS_API_SEND_JWT_SECRETS, claims team_id and hog_flow_id, 2 minute lifetime). It posts asdict(send) with a 5 second timeout. A requests.Timeout raises HogFlowApiSendTimeout. An unprovisioned key raises RuntimeError before any request. ApiSendInvocation and ApiSendEmail are @frozen dataclasses next to the client. ApiSendEmail fields: to, integration_id, from_email, from_name, reply_to, cc, bcc, subject, text, html. All are strings except integration_id, and the endpoint ticket passes "" for empty ones.
    • CDP route POST /api/projects/:team_id/hog_flows/:id/api_sends (nodejs/src/cdp/cdp-api.ts, body schema and invocation builder in nodejs/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's origin_product = api_sends flow, 409 unless active, 402 quota_limited from checkHogFlowQuotaLimits, 423 sending_paused. If every check passes, it builds a $workflows_api_send event with the caller's distinct id, or api-send-<invocation id> without one. It sets variables.email and queues one invocation with Django's id. A replayed id hits the job primary key (23505), and the route answers 200 {status: "queued", invocation_id} without a second job.
    • Exemption: the route is on the scoped-JWT list in internal-api-auth.ts. No new INTERNAL_API_SECRET caller.
    • Uncached flows: 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.
    • Schemas: api_send joins the Node trigger union and the Django TRIGGER_TYPES mirror. origin_product is loaded with the flow. The email queue schema turns a numeric string integrationId into a number.

    Proof

    • Red: the route tests posted signed requests and got 404 (no route), 13 failed. The executor rendering test stayed on queue hogflow, because the string integrationId failed the queue schema. The Django test failed to import WORKFLOWS_API_SEND_JWT_PURPOSE.
    • Green: 14/14 route tests with a real cyclotron queue (cdp-api.serial.test.ts, "hogflow api sends"). The executor test renders the queue params with Tom & Jerry, <hi> and a numeric integrationId. The 2 Django client tests pass.
    • Gate on the landed tree: all of cdp-api.serial.test.ts passed (116). src/cdp/services/hogflows, src/common/api/middleware, src/cdp/schema and the scoped JWT tests passed (427). The pre-push hogli ci:preflight --strict reported 0 failures. tsgo --noEmit reports nothing in the changed files. Its other errors are in session replay and ingestion docs files that this ticket does not touch.
    • Local, dev stack: from a Django shell, get_or_create_api_sends_flow(team 2), then create_hog_flow_api_send with a verified maildev sender on example.com:
      • first call 200 {'status': 'queued', 'invocation_id': '01a13a9f-818f-0000-6a0f-fa7d633bc7e4'}, replay 200 with the same id and no second job (the worker logged the primary key conflict)
      • maildev: subject Tom & Jerry say <hi> (ticket 470 proof), to jane@example.com, from Tom & Jerry <hello@example.com>
      • flow logs for instance 01a13a9f-…: Starting workflow execution at trigger on [Event:…|$workflows_api_send|…], Email sent to jane@example.com from Tom & Jerry <hello@example.com>, Workflow completed

    Test seams

    • The CDP route through the CDP API tests with a real CyclotronJobQueuePostgresV2. Only the quota check is a spy, on isTeamQuotaLimited.
    • The email step rendering through the existing hog flow executor tests, with the real template-email and the graph get_or_create_api_sends_flow writes.
    • The Django client, with internal_requests.post patched at the HTTP boundary.
    • The middleware exemption, as one more case in the existing scoped-route matrix.

    Calls I made

    • Purpose location: the purpose sits in posthog/plugins/plugin_server_api.py next to the other workflow CDP purposes, not in products/workflows/backend/service_jwt.py. The client in posthog/ would otherwise import from products/.
    • The route reads the primary on a miss. The lazy loader caches a missing flow for the full refresh age, and its loader reads the replica. A first send right after the flow is created could otherwise answer 404 for minutes.
    • Duplicate detection relies on the job row. It catches retries within the token lifetime. A job row that cyclotron has already cleaned up no longer blocks a replay, so the Django idempotency reservation stays the primary guard.
    • Django TRIGGER_TYPES: I added only the mirror entry. The serializer still refuses api_send as a trigger on user writes, which is right: only get_or_create_api_sends_flow writes it.

    Follow-ups

    • Setting per environment: WORKFLOWS_API_SEND_JWT_SECRET (Django WORKFLOWS_API_SEND_JWT_SECRETS, Node WORKFLOWS_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.
    • Endpoint ticket: map 402 to quota_limit_exceeded, 423 to sending_paused, and every other non-200, HogFlowApiSendTimeout and requests.ConnectionError to send_service_unavailable.
    • Frontend: the editor's trigger registry still does not know api_send.
    • Local stack: the CDP workers need personhog. With docker compose … up personhog-replica personhog-router, the replica points at the posthog database, where the person tables do not exist. I ran the replica against posthog_persons for 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.
    • No review ran in this ticket. Per the map, ship ticket Ship the email send API as one reviewed draft PR #477 reviews the branch.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions