Retargeting Funnel Deployment
ads/retargeting-funnel-deployment
| Kind | backend |
|---|---|
| Domain | ads |
| Maturity | prod |
| Reusable | extract |
| Stack | fastapi-0.118, sqlalchemy-2.0-sync, postgres, pydantic-2, httpx-0.28 |
| Depends on | _shared/fastapi-project-layout, _shared/auth-dependency-pattern, _shared/workspace-scoping, _shared/alembic-migrations |
| Frontends | ads/ui/ads-manager |
| Reference implementation |
|
| Measured on | alchemy_labs main 62ba4fc41009b454e1c033e5036c7e8577db2061 2026-09-12 |
| Version | 1 |
Purpose
One RetargetingFunnel in, five real Meta campaigns out: deploy_funnel walks a funnel's five fixed Schwartz-awareness stages in order, and for each one creates a campaign, a custom audience built from that stage's segment of the workspace's own Scribe contacts, an ad set that targets the new audience while excluding every audience already created earlier in the same run, and an ad, storing the four resulting Meta ids on the stage row (retargeting_service.py 221 to 331). The invariant that makes it trustworthy is narrower than it looks: every object Meta creates on this path is created PAUSED by construction (retargeting_deployer.py 72, 195, 276 - the literal "status": "PAUSED" in the campaign, ad set and ad payloads), so a deploy can never make a stage spend money on its own; nothing here enforces that a redeploy is safe, see Gotchas.
When to use
Read it before touching RetargetingService.deploy_funnel, _segment_contacts, pause_funnel, resume_funnel, get_funnel_metrics, or MetaRetargetingDeployer.create_campaign/create_custom_audience/create_adset/create_ad/toggle_campaign/get_campaign_insights; before changing what counts as a stage's audience (STAGE_AUDIENCE_DEFAULTS vs what _segment_contacts actually reads, see Gotchas); and when someone asks "why did a contact land in the wrong stage", "why does the ad set exclude X", "why is the daily budget only a few cents", or "why did deploy create ten campaigns instead of five". Trigger phrases: "retargeting funnel", "Schwartz stages", "5-stage funnel", "awareness stage", "custom audience exclusion", "stage budget allocation", "funnel deploy", "pause the funnel", "funnel metrics rollup".
Not this recipe: MetaRetargetingDeployer.deploy_flat_campaign (retargeting_deployer.py 665 to 884, step 8 of the same file) and its single-ad/single-video-ad retry helpers add_ad_to_adset/add_video_ad_to_adset (402 to 660) - a different, flat 1-campaign/1-adset/N-ads shape used by the cross-platform campaign flow, not the 5-stage funnel; the cross-platform orchestrator itself (campaign_setup_service.py, a separate recipe, ads/cross-platform-campaign-creation) which creates one PAUSED campaign per platform from a CampaignBrief and does not call any function in this recipe; the Meta-only campaign/ad-set CRUD elsewhere in ads_router.py (list/toggle/duplicate campaigns, ad sets, audiences, pixels, custom conversions - the "Ad set & audience endpoints" section starting at line 2258) which manages campaigns already on Meta, not funnel deploys; the Scribe tracking pipeline that populates tracking_contacts.engagement_score/engagement_level/total_sessions in the first place, out of scope here, this recipe only reads that table.
Stack assumptions
At 62ba4fc: fastapi==0.118.2, synchronous handlers on a sqlalchemy.orm.Session (SQLAlchemy==2.0.43) for every CRUD method and for the DB reads inside deploy_funnel/pause_funnel/resume_funnel/get_funnel_metrics; deploy_funnel and every MetaRetargetingDeployer method are genuinely async def and call the Meta Graph API (httpx==0.28.1, API_VERSION = "v18.0", retargeting_deployer.py 18 to 19) but the loop over the five stages inside deploy_funnel is sequential awaits, not asyncio.gather - one slow or failing Meta call blocks the rest (contrast with ads/cross-platform-campaign-creation's asyncio.gather, a different recipe); pydantic==2.x for the request/response schemas. Foundations assumed: S4 (fastapi-project-layout) for the app and mount_router, the router is mounted at /api/ads (app_fastapi.py 863: mount_router(app, ads_router.router, prefix="/api/ads", ..., surfaces=LABS_SURFACES)), so every path below is prefixed /api/ads; S5 (auth-dependency-pattern) for CurrentUser (api/dependencies.py 565, Annotated[User, Depends(get_current_user)]); S6 (workspace-scoping) for _require_workspace (ads_router.py 247 to 252), which reads X-Workspace-ID via middleware.workspace_middleware.get_workspace_id; S8 (alembic-migrations) for the one additive migration under Data model.
Environment: none dedicated to this recipe in core/config.py. The Meta credentials are per-workspace, not per-deployment: _get_tracking_settings (ads_router.py 232 to 243) reads WorkspaceSettings.settings["tracking"] and runs it through decrypt_tracking_tokens (dual-read: ciphertext decrypts, legacy plaintext passes through unchanged), and every mutating retargeting endpoint pulls meta_access_token/meta_ad_account_id from that dict, gated on tracking.get("meta_connected") being truthy. Fail behaviour: deploy_funnel sets funnel.status = FunnelStatus.error, records funnel.last_error = str(e), commits, and re-raises (retargeting_service.py 326 to 331) - the router's except RuntimeError/except Exception around the await (ads_router.py 2142 to 2147) is what turns that into a 500; pause_funnel/resume_funnel do not re-raise, a per-stage toggle_campaign failure is caught, the stage name collected into failed_stages, and the funnel put into FunnelStatus.error with a summary string, while stages that did succeed keep their new status (retargeting_service.py 350 to 370, 386 to 406); get_funnel_metrics also swallows a per-stage insights fetch failure (except Exception at 447 to 448) and simply omits that stage's numbers from the aggregate rather than failing the whole call.
Data model
retargeting_funnels, one row per funnel (models/retargeting_model.py 36 to 86; one additive migration, see Deploy & ops):
retargeting_funnels (
id uuid PRIMARY KEY,
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
created_by uuid NOT NULL REFERENCES users(id),
name varchar NOT NULL,
status funnelstatus NOT NULL DEFAULT 'draft', -- draft|deploying|active|paused|error
total_budget numeric(12,2) NOT NULL DEFAULT 0,
currency varchar(3) DEFAULT 'CHF',
budget_allocation jsonb DEFAULT '{}', -- { "unaware": 35, "problem_aware": 25, "solution_aware": 20, "product_aware": 12, "most_aware": 8 } by convention, NOT enforced
platform_distribution jsonb DEFAULT '{}', -- { "meta": 100 } by convention; other platform keys are accepted and stored but never deployed to, see Gotchas
ad_group_id varchar,
last_error text,
created_at, updated_at timestamptz
);
-- index: ix_retargeting_funnels_workspace_status (workspace_id, status)
retargeting_stages, five rows per funnel, one per AwarenessStage (models/retargeting_model.py 89 to 140):
retargeting_stages (
id uuid PRIMARY KEY,
funnel_id uuid NOT NULL REFERENCES retargeting_funnels.id,
stage awarenessstage NOT NULL, -- unaware|problem_aware|solution_aware|product_aware|most_aware
stage_order integer NOT NULL, -- 1-5, matches STAGES_ORDERED
headline varchar,
primary_text text,
description text,
cta_type varchar, -- one of CTA_TYPES (schemas, 29-33), validated only on write via StageContentUpdate
landing_url varchar,
creatives jsonb DEFAULT '[]', -- [{ file_name, content_type, uploaded_by }, ...] - file bytes are NOT stored here, see below
audience_config jsonb DEFAULT '{}', -- per-stage filter description; read by no code path at deploy time, see Gotchas
platform_campaigns jsonb DEFAULT '{}', -- { "meta": { campaign_id, adset_id, ad_ids: [...], audience_id } } after deploy
status varchar DEFAULT 'draft', -- draft|active|paused|error, free text, no CHECK
metrics_snapshot jsonb DEFAULT '{}', -- last metrics fetched by get_funnel_metrics
created_at, updated_at timestamptz
);
-- indexes: ix_retargeting_stages_funnel_id (funnel_id), ix_retargeting_stages_funnel_stage (funnel_id, stage)
No CHECK constraint anywhere in this migration: budget_allocation percentages are not verified to sum to 100, status on either table is a plain varchar/enum with no cross-table shape rule, and nothing stops two retargeting_stages rows sharing one (funnel_id, stage) (no unique index on that pair; RetargetingService.create_funnel only ever inserts one row per stage, but nothing in the schema enforces it).
What is NOT stored: the segmented contact emails themselves - _segment_contacts computes stage_name -> [email, ...] fresh on every deploy_funnel call from tracking_contacts and never persists the list (retargeting_service.py 465 to 507); the uploaded creative's file bytes - upload_stage_creative stores only file_name/content_type/uploaded_by in creatives (ads_router.py 2092 to 2097), where the bytes themselves land is not part of this recipe; the Meta access token itself, which lives in WorkspaceSettings, not on the funnel or stage row.
API surface
All paths below are mounted under /api/ads (ads_router.py 1979 to 2250). Every route takes CurrentUser and reads X-Workspace-ID via _require_workspace.
| Route | Auth dep | Tenancy | Codes | Notes |
|---|---|---|---|---|
POST /retargeting | CurrentUser | _require_workspace | 200 FunnelResponse; 401; 400 (missing header) | Creates a draft funnel with 5 empty stages in one call; no explicit status_code=201 (1979-1999) |
GET /retargeting | CurrentUser | _require_workspace | 200 List[FunnelListItem] (no stages) | 2002-2011 |
GET /retargeting/{funnel_id} | CurrentUser | _require_workspace | 200 FunnelResponse; 404 | 2014-2026 |
PUT /retargeting/{funnel_id} | CurrentUser | _require_workspace | 200 FunnelResponse; 404 | **body.model_dump(exclude_none=True), 2029-2047 |
PUT /retargeting/{funnel_id}/stages/{stage} | CurrentUser | _require_workspace | 200 StageResponse; 400 (_validate_stage, unknown stage name); 404 | 2050-2071 |
POST /retargeting/{funnel_id}/stages/{stage}/upload | CurrentUser | _require_workspace | 200 {success, creative}; 400 (bad stage or no file field in the multipart form); 404 | 2074-2109 |
POST /retargeting/{funnel_id}/deploy | CurrentUser | _require_workspace | 200 {success, funnel_id, status}; 400 (Meta not connected); 500 (RuntimeError from the deployer, or any other exception) | 2112-2147 |
POST /retargeting/{funnel_id}/pause | CurrentUser | _require_workspace | 200 {success, status}; 400 (Meta not connected); 404 | Per-stage failures do not raise; funnel ends in error if any stage failed (2150-2173) |
POST /retargeting/{funnel_id}/resume | CurrentUser | _require_workspace | 200 {success, status}; 400 (Meta not connected); 404 | Same shape as pause (2176-2199) |
GET /retargeting/{funnel_id}/metrics | CurrentUser | _require_workspace | 200 FunnelMetrics; 400 (Meta not connected); 404 | A GET that writes: commits stage.metrics_snapshot as a side effect (2202-2235) |
DELETE /retargeting/{funnel_id} | CurrentUser | _require_workspace | 200 {success: true}; 404 | Cascades to stages via the model's cascade="all, delete-orphan" (2238-2250) |
Application logic
create_funnel (retargeting_service.py 69-112): builds budget_allocation from the caller's dict or DEFAULT_BUDGET_ALLOCATION.copy() (35/25/20/12/8), platform_distribution from the caller's dict or {"meta": 100}; inserts the funnel, flushes to get its id, then inserts one RetargetingStage per entry in STAGES_ORDERED (25-31, stage_order 1 through 5) seeded with STAGE_AUDIENCE_DEFAULTS.get(stage.value, {}) (34-60) as audience_config; one db.commit() at the end.
deploy_funnel (221-331), in commit order:
- Load the funnel (workspace-scoped);
ValueError("Funnel not found")if absent (241-243). - Set
funnel.status = FunnelStatus.deploying, commit immediately (245-246) - this is the only checkpoint written before any Meta call. _segment_contacts(db, funnel)(250) - see below; result isDict[str, List[str]], five keys, never empty lists removed.- For each stage in
funnel.stages(relationship-ordered bystage_order, i.e.unawarefirst): create the campaign (meta_deployer.create_campaign, objective fromSTAGE_OBJECTIVE_MAP); create the custom audience from that stage's segmented emails (create_custom_audience); computedaily_budget = total_budget * (budget_allocation.get(stage_name, 20) / 100) * (platform_distribution.get("meta", 100) / 100), floored atmax(int(daily_budget * 100), 100)cents - a hardcoded $1/day minimum regardless offunnel.currency(276-289, see Gotchas); create the ad set passingexcluded_audience_ids=list(created_audience_ids), the audiences created by every stage processed so far in this same call (291); create the ad from the stage's own copy fields, defaultingcta_typetoLEARN_MOREif unset (295-306); write all four resulting ids intostage.platform_campaigns["meta"], setstage.status = "active", append the new audience id tocreated_audience_ids(309-318). - After all five stages:
funnel.status = FunnelStatus.active, onedb.commit()(320-321). On any exception during steps 3-4, theexceptblock setsfunnel.status = FunnelStatus.errorandfunnel.last_error, then commits and re-raises (326-331) - because the session already holds the mutated (but never-yet-committed)stage.platform_campaigns/statusfor whichever stages succeeded before the failure, this commit persists their real Meta ids too, even though the run as a whole is reported as failed (see Gotchas: no idempotency on retry).
_segment_contacts (465-507): one yield_per(2000) query over tracking_contacts filtered to workspace_id == funnel.workspace_id AND email IS NOT NULL (475-482); classifies each contact into exactly one of the five stages by a fixed if/elif chain checked in this order - engagement_level == "high_intent" and engagement_score >= 70 → most_aware; else engagement_score >= 50 → product_aware; else total_sessions >= 2 → solution_aware; else total_sessions == 1 → problem_aware; else → unaware (492-502, tracking_model.py 164/169/170 for the three source columns). This chain is entirely independent of each stage's audience_config (see Gotchas).
pause_funnel/resume_funnel (336-406): iterate funnel.stages, read stage.platform_campaigns.get("meta", {}).get("campaign_id"), skip stages with no campaign id, call meta_deployer.toggle_campaign(access_token, campaign_id, pause=True/False) per stage inside its own try/except; on success set stage.status = "paused"/"active"; on failure append the stage name to failed_stages and log, without touching that stage's status. One db.commit() after the loop; funnel.status becomes FunnelStatus.error (with last_error naming the failed stages) if failed_stages is non-empty, else paused/active.
get_funnel_metrics (411-460): per stage with a stored campaign_id, calls meta_deployer.get_campaign_insights (30-day window by default), writes the raw per-stage dict into stage.metrics_snapshot, and accumulates spend/impressions/clicks/conversions into a total dict; derives ctr/cpc after the loop, guarded against division by zero (451-452); one db.commit() persists every touched metrics_snapshot.
MetaRetargetingDeployer.create_campaign/create_custom_audience/create_adset/create_ad (retargeting_deployer.py 53-290): each is one or two httpx.AsyncClient calls to {BASE_URL}/{account_id}/... with a 30 second timeout (60s for the audience email upload), account id normalized to the act_ prefix by _ensure_act_prefix (40-44); every one of the four raises RuntimeError(f"Meta API error: {message}") on a data["error"] response body, there is no retry. create_custom_audience hashes emails with sha256(email.lower().strip()) before upload and caps the batch at Meta's 10,000-row limit (134-142); an empty emails list skips the upload call entirely, the audience is still created. toggle_campaign (295-317) is a single POST {campaign_id} with status=PAUSED|ACTIVE.
Invariants
- Every campaign, ad set and ad this recipe creates is created
PAUSED: verified at the literal string in all three payload builders (retargeting_deployer.py72, 195, 276); nothing in this recipe ever sets a Meta object toACTIVEexcepttoggle_campaign(pause=False), and that only ever targets a campaign, never the ad set or the ads underneath it - resuming a funnel does not resume its ad sets or ads if they were paused independently of the funnel-level toggle (no code path calls the ad-set or ad status endpoints fromresume_funnel). - A stage's audience at deploy time is fully determined by
_segment_contacts's fixed threshold chain, never by that stage's ownaudience_config- confirmed by greppingretargeting_service.pyforaudience_config: it is written (seeded increate_funnel, updatable viaupdate_stage) but never read anywhere in the file. _require_workspacecloses fail-safe: every route 400s before touching the database ifX-Workspace-IDis absent (ads_router.py247-252), and every service method additionally filters byworkspace_idin its own query (get_funnel, 117-126), so a funnel id from another workspace 404s rather than leaking.- Deploying a funnel more than once is not guarded:
deploy_funnelnever checks whetherstage.platform_campaignsalready has ametaentry before creating new Meta objects, so a second call against an already-deployed (or partially-deployed) funnel creates five more campaigns rather than erroring or resuming (grep confirms no read ofplatform_campaignsanywhere indeploy_funnel's body before the write at line 309).
Frontend
The module that would render this, by id:
ads/ui/ads-manager the ads management surface this recipe's endpoints are the backend of.
At 62ba4fc the actual frontend calling every one of these routes lives
outside this recipe's own numbering, at
alchemy_labs/frontend/src/pages/RetargetingBuilder.tsx (609 lines, the
per-stage editor: copy, CTA, creative upload, deploy/pause/resume,
metrics display) and RetargetingList.tsx (198 lines, the funnel list),
via alchemy_labs/frontend/src/services/retargetingService.ts (BASE =
'/api/ads/retargeting', one function per route above). Cited for
context; this recipe does not claim those exact files as its own
rendering.
Minimum admin/debug surface (no dedicated route exists at this commit): SELECT id, name, status, last_error FROM retargeting_funnels WHERE status = 'error' ORDER BY updated_at DESC (funnels stuck by a failed deploy, pause, or resume); SELECT funnel_id, stage, status, platform_campaigns->'meta'->>'campaign_id' FROM retargeting_stages WHERE platform_campaigns != '{}' ORDER BY funnel_id, stage_order (what is actually live on Meta per funnel, the list a human reconciles against Ads Manager before trusting status); the log lines [RETARGET] Deployed funnel %s successfully, [RETARGET] Deploy failed for funnel %s: %s, [RETARGET] Pause failed for campaign %s: %s / Resume failed for campaign %s: %s (retargeting_service.py 323, 330, 361, 397).
Security & tenancy
Auth dependency: CurrentUser (S5), JWT bearer/cookie/API-key backed get_current_user (api/dependencies.py 187 to roughly 250), 401 on anything that fails to validate. Workspace derivation: X-Workspace-ID header read by _require_workspace via get_workspace_id(request) (workspace_middleware.py 393), 400 if missing; every service call additionally filters by workspace_id in SQL, never by session/token alone (S6). Fail direction: the header check closes (400, not a silent default workspace); _validate_stage closes on an unrecognized stage name (400, ads_router.py 1969-1976); the Meta-connection check closes (if not access_token or not account_id: raise HTTPException(400, ...) before deploy/pause/resume/metrics ever run).
Secrets at rest: the Meta access token and ad account id are never stored on the funnel or stage row; they live in WorkspaceSettings.settings["tracking"], read through decrypt_tracking_tokens (dual-read: ciphertext decrypts, legacy plaintext passes through) - this recipe consumes that seam, it does not own encrypting it. Rate limits: none dedicated to this recipe; create_custom_audience's own rate limiting is Meta's, not Labs'. Attacker-controlled inputs: stage (path param, validated against AWARENESS_STAGES, 400 on anything else); funnel_id (UUID-typed by FastAPI, 422 on a non-UUID before the handler runs); cta_type (validated against CTA_TYPES by StageContentUpdate.validate_cta, schemas 49-54, a ValueError there becomes FastAPI's standard 422); headline/primary_text/description/landing_url (free text, sent to Meta verbatim as ad copy and a link - Meta's own ad-review is the only content filter, this recipe applies none); contact emails are hashed with SHA-256 before ever leaving Labs for Meta (create_custom_audience, 134-137), never sent plaintext.
Tests
No test file exists for this recipe at 62ba4fc. find backend/tests -iname '*retarget*' (run 2026-09-12 against the main worktree at this commit) returns nothing; a repo-wide grep -rl "retargeting_service\|retargeting_deployer\|RetargetingFunnel\|meta_deployer\." backend/tests also returns nothing. RetargetingService, MetaRetargetingDeployer, _segment_contacts's classification chain, and the pause/resume partial-failure bookkeeping are all unexercised by any automated test in this repo as of this commit - this is stated plainly rather than invented, the task's own file list (test_retargeting_service.py, test_retargeting_deployer.py) does not correspond to anything on disk at main 62ba4fc. A rebuild has no known-answer vectors, no hermetic fixtures, and no concurrency proof to port; it starts from zero.
Deploy & ops
Config keys: none dedicated (credentials are per-workspace, see Security & tenancy). Migration: retargeting_001 (backend/migrations/versions/20260321_add_retargeting_funnels.py), down_revision = tracking_perf_idx_001; confirmed via alembic.script.ScriptDirectory.from_config(...).walk_revisions(base='base', head='prima_device_codes_001') (not by text-parsing down_revision lines across files, which under-links a handful of revisions and falsely reports several disconnected heads, see Gotchas) that retargeting_001 is an ancestor of prima_device_codes_001, the repo's single real alembic head at this commit (alembic heads prints exactly one line). The migration creates two Postgres-native enums (funnelstatus, awarenessstage) guarded by an existence check before CREATE TYPE, and both tables guarded by _table_exists, so it is safe to re-run against a partially-applied database. Scheduler: none - grep -n retarget backend/services/scheduled_jobs.py matches nothing; get_funnel_metrics is pull-only, invoked by the frontend on demand, there is no background job refreshing metrics_snapshot. Rollback: downgrade() drops both tables then both enums (119-128); a retargeting_funnels row cascades to its stages via the FK's ORM-level cascade="all, delete-orphan" (there is no ON DELETE CASCADE on retargeting_stages.funnel_id at the database level in this migration, only in the SQLAlchemy relationship, so a raw DB delete of a funnel row outside the ORM would violate the FK rather than cascade).
Gotchas
- 2026-09-12 (measured at 62ba4fc):
deploy_funnelhas no idempotency guard. It never checks whetherstage.platform_campaignsalready carries ametaentry before creating new Meta objects, so calling deploy twice on the same funnel creates five more campaigns, audiences, ad sets and ads rather than erroring, resuming, or reconciling. Worse, a deploy that fails partway (say on stage 3 of 5) still commits the real Meta ids already written into memory for stages 1-2 before the exception (theexceptblock'sdb.commit()flushes the whole dirty session, not justfunnel), so a funnel leftstatus=errorcan already own live (paused) orphan campaigns for its earlier stages, and a retry does not know about them. - 2026-09-12 (measured at 62ba4fc):
audience_configonRetargetingStageis seeded with a real per-stage description at funnel creation (STAGE_AUDIENCE_DEFAULTS,retargeting_service.py34-60) and is user-editable viaPUT /retargeting/{funnel_id}/stages/{stage}, but_segment_contactsnever reads it - the actual segmentation at deploy time is a hardcoded five-branch if/elif chain onengagement_level/engagement_score/total_sessions(465-507). Editing a stage's audience configuration in the UI has no effect on which contacts are targeted. - 2026-09-12 (measured at 62ba4fc):
platform_distributionaccepts and stores arbitrary platform keys (e.g.{"meta": 60, "google": 40}), anddeploy_funnel's own budget math readsplatform_distribution.get("meta", 100)as a multiplier, implying other platforms are accounted for - butdeploy_funnelonly ever imports and callsservices.retargeting_deployer.meta_deployer; there is no Google or LinkedIn deploy path anywhere in this recipe. A funnel configured with{"meta": 50, "google": 50}still gets 100% of its Meta-allocated budget deployed to Meta alone, with the other 50% simply never spent anywhere. - 2026-09-12 (measured at 62ba4fc): the daily budget floor is a hardcoded
max(daily_budget_cents, 100), i.e. 100 minor units, treated as "$1/day" in the source comment (retargeting_service.py289) regardless offunnel.currency- a CHF or EUR funnel gets the same numeric floor as a USD one, with no currency-aware conversion. - 2026-09-12 (measured at 62ba4fc): naive text-parsing of
down_revision/revisionacross every file inmigrations/versions/(a plain regex over the ~90 files, no alembic import) reports seven disconnected "heads" includingretargeting_001's own branch terminus. Loading the realalembic.script.ScriptDirectoryreports exactly one head (prima_device_codes_001) and confirmsretargeting_001is its ancestor. The regex result is wrong, not evidence of a real multi-head migration tree; do not use it to judge maturity on this or any other recipe in this repo.
Reference implementation
Paths relative to the repo root; all at main 62ba4fc41009b454e1c033e5036c7e8577db2061 unless noted.
alchemy_labs/backend/services/retargeting_service.py(524 lines): module docstring (1-6);STAGES_ORDERED/STAGE_AUDIENCE_DEFAULTS(25-60);create_funnel(69-112);get_funnel/list_funnels/get_stage(117-151);update_funnel/update_stage/add_creative_to_stage(156-216);deploy_funnel(221-331);pause_funnel/resume_funnel(336-406);get_funnel_metrics(411-460);_segment_contacts(465-507);delete_funnel(512-519).alchemy_labs/backend/services/retargeting_deployer.py(889 lines, module docstring says "Phase 1: Meta (Facebook) Ads only", 1-8):STAGE_OBJECTIVE_MAP/STAGE_OPTIMIZATION_MAP(22-37);_ensure_act_prefix(40-44);create_campaign(53-89);create_custom_audience(94-155);create_adset(160-210);create_ad(215-290);toggle_campaign(295-317);get_campaign_insights(322-361); everything from 363 onward (upload_image_to_meta,add_ad_to_adset, video upload/wait/add,deploy_flat_campaign) belongs to the flat-campaign recipe, out of scope here.alchemy_labs/backend/models/retargeting_model.py(141 lines, whole file in scope):FunnelStatus/AwarenessStageenums (20-33);RetargetingFunnel(36-86);RetargetingStage(89-140).alchemy_labs/backend/schemas/retargeting_schemas.py(165 lines, whole file in scope):AWARENESS_STAGES/DEFAULT_BUDGET_ALLOCATION/DEFAULT_PLATFORM_DISTRIBUTION/CTA_TYPES(15-33);StageContentUpdate/StageResponse(40-77);FunnelCreate/FunnelUpdate/FunnelResponse/FunnelListItem(83-146);FunnelMetrics(152-165).alchemy_labs/backend/api/routers/ads_router.py:_require_workspace(247-252);_get_tracking_settings(232-243); the retargeting block (1952-2250) -_validate_stage(1969-1976), the eleven routes as listed under API surface.alchemy_labs/backend/migrations/versions/20260321_add_retargeting_funnels.py(128 lines, whole file in scope).alchemy_labs/backend/models/tracking_model.py:TrackingContact.total_sessions/engagement_score/engagement_level(164, 169, 170), the three columns_segment_contactsreads.
Deviations of the reference from an ideal implementation, all dated 2026-09-12: no test coverage of any kind; no idempotency check on redeploy; audience_config is dead code for segmentation purposes; platform_distribution implies multi-platform support the deploy path does not provide; the budget floor ignores currency; no CHECK constraint anywhere in the migration.
Acceptance checks
Run from alchemy_labs/backend at 62ba4fc (or an app built to this recipe), read-only, no live database required for checks 1-6.
grep -n '"status": "PAUSED"' services/retargeting_deployer.pyprints three lines (72, 195, 276) - one per Meta object type this recipe creates, confirming the pause-by-construction invariant.grep -n "audience_config" services/retargeting_service.pyprints exactly two lines, both insidecreate_funnel's stage-seeding loop andSTAGE_AUDIENCE_DEFAULTS.get(...)(105) plus the dict's own key (34, part of the module-level constant) - no line inside_segment_contacts(465-507) references it, confirming the dead-audience-config gotcha.grep -rl "retargeting_service\|retargeting_deployer\|RetargetingFunnel" tests/ 2>/dev/null(frombackend/) prints nothing, confirming the Tests section's "no coverage" claim..venv/bin/python -c "from alembic.config import Config; from alembic.script import ScriptDirectory; cfg=Config('alembic.ini'); s=ScriptDirectory.from_config(cfg); print(s.get_heads()); print('retargeting_001' in [r.revision for r in s.walk_revisions('base','prima_device_codes_001')])"prints['prima_device_codes_001']thenTrue.grep -n "^const BASE" ../frontend/src/services/retargetingService.tsprintsconst BASE = '/api/ads/retargeting';, confirming the mount prefix (/api/ads,app_fastapi.py863) plus the router's own/retargetingpath.grep -n "meta_pct = " services/retargeting_service.pyshows the multiplier is read fromplatform_distribution.get("meta", 100)(278) whilegrep -c "google_adapter\|linkedin_adapter\|GoogleAdsClient\|LinkedInAdsService" services/retargeting_service.py services/retargeting_deployer.pyprints0, confirming no non-Meta deploy path exists despiteplatform_distributionaccepting non-Meta keys.
Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14. The recipe file is the source; this page is a rendering of it.