Prima Get an API key

Module kit / Ads

LinkedIn Ads Campaigns and Matched Audiences

ads/linkedin-ads-campaigns-and-audiences

Kind backend
Domain ads
Maturity prod
Reusable extract
Stack fastapi-0.118.2, httpx-0.28.1, pydantic-2.12.0
Depends on _shared/fastapi-project-layout, _shared/auth-dependency-pattern, _shared/workspace-scoping, _shared/test-layout
Frontends ads/ui/ads-manager
Reference implementation
  • alchemy_labs/backend/services/linkedin_ads_service.py
  • alchemy_labs/backend/services/campaign_setup/adapters/linkedin_adapter.py
  • alchemy_labs/backend/schemas/linkedin_ads_schemas.py
  • alchemy_labs/backend/api/routers/linkedin_ads_router.py
  • alchemy_labs/backend/api/routers/audience_sync_router.py
  • alchemy_labs/backend/tests/test_linkedin_ads.py
Measured on alchemy_labs main 62ba4fc41009b454e1c033e5036c7e8577db2061 2026-09-12
Version 1

Purpose

A workspace with a connected LinkedIn ad account can CRUD campaign groups and campaigns, pull adAnalytics reporting without hitting LinkedIn's 20-field-per-request cap, and sync two kinds of matched audience into a LinkedIn DMP segment: hashed-email warm audiences from a workspace's own consented contacts, and a hard-guarded company/ABM path for cold prospects that can never carry a person's email. The invariant that makes it trustworthy is the one enforced in add_companies_to_dmp_segment: any entry containing @ raises ValueError before a single HTTP request is made (services/linkedin_ads_service.py 696-724), so personal data structurally cannot reach the company-targeting path even if a caller's list was built carelessly - verified by test_email_hard_guard_refuses_whole_call asserting fake_httpx.calls == [] (tests/test_linkedin_ads.py 700-711).

When to use

Read it before touching LinkedInAdsService.create_campaign_group/create_campaign/update_*/delete_*, get_ad_analytics, add_users_to_dmp_segment, or add_companies_to_dmp_segment; before changing the RestLi query-string builders (_restli_list, _date_range, _encode_urn); and when someone asks "why did the LinkedIn analytics call only return 20 metrics", "why did the company-audience upload reject my list", or "is this campaign actually live" (everything this service creates is forced to status: "PAUSED", see Application logic). Trigger phrases: "LinkedIn Marketing API", "adAnalytics", "DMP segment", "matched audience", "SHA256_EMAIL", "rw_dmp_segments", "rw_ads", "COMPANY_WEBSITE_DOMAIN", "ABM warm-up", "RestLi".

Not this recipe: the LinkedIn Conversions API pixel/event-forwarding path (LinkedInCAPIService, a separate module hash_user_identifier's docstring points at as the source of its own normalization rule, out of scope here); the multiplatform campaign-setup orchestrator itself (services/campaign_setup/campaign_setup_service.py, api/routers/campaign_setup_router.py, the brief -> objective/budget/targeting/creative translation shared across Meta/Google/LinkedIn) - this recipe only covers what LinkedInAdapter.create calls into on the LinkedIn side, not how the brief is built; the LinkedIn OAuth connect/disconnect flow (/api/linkedin/connect, referenced from TrackingSettings.tsx, not read as part of this recipe); the Phase-2 scaffolds in the same service file - initialize_image_upload, create_post, create_creative, list_creatives, get_targeting_facets - all of which raise LinkedInAdsError(status_code=501) via _not_implemented (services/linkedin_ads_service.py 554-591, 789-791) and are proven still-501 by test_phase2_scaffolds_raise_501 (tests/test_linkedin_ads.py 522-531).

Stack assumptions

At 62ba4fc: fastapi==0.118.2, httpx==0.28.1 (async client, a fresh httpx.AsyncClient(timeout=...) per call, not a shared pool), pydantic==2.12.0 for the request/response schemas. No database table exists for this recipe - LinkedIn's own DMP segment and campaign entities are the store; the only Labs-side persistence is the OAuth token pair inside WorkspaceSettings.settings["tracking"], encrypted at rest via services.security.oauth_token_crypto.encrypt_token/decrypt_tracking_tokens (linkedin_ads_router.py 53-86; audience_sync_router.py 199-229). Foundations assumed: S4 (fastapi-project-layout) for the two routers this recipe mounts into; S5 (auth-dependency-pattern) for CurrentUser/get_current_user; S6 (workspace-scoping) for _require_workspace/WorkspaceID deriving the tenant from the request, never from the body; S9 (test-layout) for the pytest layout tests/test_linkedin_ads.py follows (no conftest.py fixtures beyond the module-local fake_httpx/linkedin_creds, no database).

Environment, all in core/config.py: LINKEDIN_CLIENT_ID (989), LINKEDIN_CLIENT_SECRET (990, SecretStr) - both required for the OAuth refresh; missing either raises LinkedInAdsError(status_code=500) before the refresh POST is attempted (services/linkedin_ads_service.py 272-277). LINKEDIN_ABM_ENABLED (958, default=False) dark-404s the entire company/ABM router surface (_require_abm, audience_sync_router.py 384-387) independent of the email hard-guard. Fail behaviour: every method on LinkedInAdsService is async and raises LinkedInAdsError on any LinkedIn response >= 400, carrying the response body verbatim in .details (310-380); nothing in the service itself swallows an error the way billing-credits/auto-topup-charge-and-retry's webhook wrapper does - the routers are the layer that maps LinkedInAdsError to an HTTPException (_raise_http, linkedin_ads_router.py 134-140) or to a 502 (audience_sync_router.py, each endpoint's own except LinkedInAdsError).

Data model

None. This recipe stores nothing of its own beyond the OAuth token pair already covered by the connect flow (out of scope here). LinkedIn's dmpSegment, sponsoredCampaignGroup and sponsoredCampaign entities are the system of record; Labs holds only their ids/URNs transiently in request/response bodies. What is NOT stored anywhere in this recipe: a raw email address past the point of hashing (hash_user_identifier runs before any HTTP call is built, services/linkedin_ads_service.py 640-660); the plaintext OAuth token (encrypted via encrypt_token before the WorkspaceSettings.settings JSON column is written, linkedin_ads_router.py 72-74, audience_sync_router.py 522-524).

API surface

Two routers. linkedin_ads_router.router is mounted with no router-level prefix at /api/ads (app_fastapi.py 867, so paths below read /api/ads/linkedin/...); audience_sync_router.router carries its own prefix="/api/admin/audiences" (audience_sync_router.py 41, so its paths read /api/admin/audiences/linkedin/...). Auth on every route below: CurrentUser/get_current_user plus _require_workspace/WorkspaceID (S5/S6); the admin-audiences router additionally calls _require_admin(current_user) (audience_sync_router.py 44-47, 403 if not user.is_admin) on every route in the file, including the plain LinkedIn segment-sync block below, not only the company/ABM block; its /linkedin/company/* and /linkedin/campaigns/* routes additionally call _require_abm() first, which 404s (not 403) when LINKEDIN_ABM_ENABLED is unset - the plain /linkedin, /linkedin/preview, /linkedin/create, /linkedin/{id}/sync routes carry no such flag gate, only the admin check.

audience_sync_router.py in fact holds two distinct LinkedIn audience surfaces, both admin-only, that this recipe covers in full: a plain hashed-email segment-sync block (249-369, no ABM flag) that pulls the calling workspace's own consented TrackingContact rows and is the closest thing in the codebase to a "one-click sync my leads to LinkedIn" flow; and the company/ABM block (372-580) already covered under linkedin_ads_service's add_companies_to_dmp_segment/resolve_company_urns above.

RouteAuth extrasWrites gated byCodesNotes
GET /api/ads/linkedin/accounts/{id}/campaign-groups--200; 401/403/404/502 mapped from LinkedIn via _raise_httplinkedin_ads_router.py 193-203
GET .../campaign-groups/{group_id}--same206-215
POST .../campaign-groups-_gate_write (below)201 CreatedEntityResponse; 403 if scopes known and lack rw_ads218-243; currency auto-filled from GET /adAccounts/{id} when total_budget.currency_code omitted (143-150)
PATCH .../campaign-groups/{group_id}-_gate_write200; 400 if to_patch() produces no fields246-268
DELETE .../campaign-groups/{group_id}-_gate_write200 {"id", "deleted": true}271-282
GET/POST/PATCH/DELETE .../campaigns[/{id}]-_gate_write on writessame shape as campaign groups289-388
GET .../accounts/{id}/analytics-n/a (reporting only needs r_ads_reporting)200 AnalyticsResponse395-438; fields/campaign_ids are comma-separated query params, split and chunked server-side
POST /api/ads/linkedin/audiences-_gate_write201496-513, calls create_dmp_segment(segment_type="USER") by default
POST /api/ads/linkedin/audiences/{segment_id}/users-_gate_write200516-533, hashed-email upload; zero frontend caller found (grep, 2026-09-12)
GET /api/admin/audiences/linkedin/preview_require_admin-200 {"available_contacts","warning"}audience_sync_router.py 249-270, counts the workspace's own TrackingContact rows with a non-null email
GET /api/admin/audiences/linkedin_require_admin-200 {"segments"}273-293, wraps list_dmp_segments; catches LinkedInAdsError AND bare Exception, both to 502
POST /api/admin/audiences/linkedin/create_require_admin-200 {"success","warning",...}296-317, create_dmp_segment with the default segment_type="USER"
POST /api/admin/audiences/linkedin/{segment_id}/sync_require_admin-200 {"success","synced_count","skipped","segment_id","warning"}320-369, pulls every TrackingContact in the calling workspace with a non-null email (workspace-filtered query, 335-340) and uploads all of them via add_users_to_dmp_segment in one call - no batching by the caller beyond what the service does internally
GET /api/admin/audiences/linkedin/company/resolve_require_abm + _require_admin-200; 502 on LinkedInAdsError416-434
POST /api/admin/audiences/linkedin/company/create_require_abm + _require_admin-200 {"success", "warning", ...}436-455, segment_type="COMPANY" (not the COMPANY_LIST_UPLOAD string the AudienceCreate schema's description mentions as an example - the code path actually exercised, by both this router and test_create_company_segment_type, always sends "COMPANY")
POST /api/admin/audiences/linkedin/company/{segment_id}/add_require_abm + _require_admin-200; 400 on the email hard-guard ValueError, 502 on a LinkedIn-side error458-480; this is the only route in any of these routers that turns a ValueError into a 400 rather than a 500
POST /api/admin/audiences/linkedin/company/{segment_id}/campaign_require_abm + _require_admin-200483-523, builds a CampaignCreate with targeting=TargetingCriteria(audiences=[segment_id]) and a required daily_budget_amount (no default - "the hard cap"), always status: "PAUSED"
POST /api/admin/audiences/linkedin/campaigns/{id}/activate_require_abm + _require_admin-200526-558, flips a campaign to ACTIVE and re-states the daily-budget cap in the same call rather than trusting whatever was set at creation
POST /api/admin/audiences/linkedin/campaigns/{id}/pause_require_abm + _require_admin-200561-580, docstring says "used by the Aurum warm-up timer at warm-end" (568) - no such caller exists anywhere in the repo at 62ba4fc (Gotchas)

_gate_write (linkedin_ads_router.py 115-131): reads tracking["linkedin_granted_scopes"]; if that list is known and does not contain rw_ads, raises 403 before calling LinkedIn at all; if the scopes were never persisted (a token connected before scope-persistence shipped), the call is attempted and LinkedIn's own 403 body is surfaced verbatim through _raise_http.

The Python entrypoints both routers call into (services/linkedin_ads_service.py), the actual surface a rebuild reimplements:

FunctionLinesReturns
create_campaign_group / update_campaign_group / delete_campaign_group416-443{"id","urn"} / {"id","updated":[...]} / {"id","deleted":true}
create_campaign / update_campaign / delete_campaign466-493same shapes
get_ad_analytics(*, start, end, pivot, time_granularity, fields, campaign_ids, campaign_group_ids, account_ids, **auth)497-546List[dict], one normalized row per (pivotValues, dateRange) key
add_users_to_dmp_segment(segment_id, emails, action="ADD"|"REMOVE", **auth)640-688{"segment_id","uploaded","skipped"}
add_companies_to_dmp_segment(segment_id, companies, action="ADD"|"REMOVE", **auth)696-753same shape; raises ValueError before any request on an email-like entry
resolve_company_urns(query, locale="de_CH", **auth)755-785[{"urn","id","name"}], cached per (query.strip().lower(), locale) in a module-level dict capped at COMPANY_URN_CACHE_MAX = 1024 (60-61, 782-784: cleared entirely, not LRU-evicted, once full)

Application logic

Campaign-group/campaign CRUD (416-493): each write is a thin wrapper - create_* POSTs the caller's payload dict as-is and reads the new id from the x-restli-id response header via _extract_created_id (142-156, falls back to x-linkedin-id, unwraps a percent-encoded URN with unquote and takes the trailing :-segment); update_* is a POST with header X-RestLi-Method: PARTIAL_UPDATE and body {"patch": {"$set": set_fields}} (426-437, 476-487 - RestLi has no native PATCH verb, this is how LinkedIn's API expresses one); delete_* is a plain DELETE. None of these four methods validate their input payload - that validation lives one layer up, in CampaignGroupCreate/CampaignCreate/*Update.to_linkedin()/.to_patch() (schemas/linkedin_ads_schemas.py), which hard-code status: "PAUSED" into every create (141-174, 210-273, both docstrings: "Always created PAUSED", "status is not accepted from the client") - there is no code path in either schema that lets a caller create something already ACTIVE.

Two independent callers build that payload differently, both landing on the same four CRUD methods:

  1. linkedin_ads_router.py's own POST endpoints take the typed CampaignGroupCreate/CampaignCreate Pydantic bodies and call .to_linkedin(account_id, currency) (238-243, 341-347), auto-fetching the ad account's currency first when a budget/bid omits currency_code (143-150, 234-236, 259-261, 336-340, 363-367).
  2. LinkedInAdapter.build_payload/.create (services/campaign_setup/adapters/linkedin_adapter.py) builds an equivalent dict by hand, not through the Pydantic schema - it computes runSchedule.start from brief.schedule.start itself (86-100, falls back to "now" on an unparsable ISO string, appending a warning rather than failing), hard-codes status: "PAUSED" again independently (127), and creates the campaign group first, then threads group.get("urn") into campaignGroup on the campaign body (175-184) - the same two-step order to_linkedin()'s campaignGroup URN construction expresses declaratively. The import is guarded (try/except around from services.linkedin_ads_service import LinkedInAdsService, 31-34) so a deployment missing the module degrades this adapter to status: "skipped" rather than crashing the orchestrator; per-platform failure isolation is explicit - every exception in .create() is caught and returned as PlatformSetupResult(status="failed", error=str(e)) rather than propagated (201-203).

adAnalytics chunking/merging (497-546): LinkedIn rejects a fields list longer than ANALYTICS_MAX_FIELDS = 20 (64) in one request. get_ad_analytics reserves room for ANALYTICS_MERGE_KEYS = ("pivotValues", "dateRange") (65-66) by chunking the caller's metric fields at 20 - 2 = 18 (533), prepending both merge keys to every chunk's fields= param (536-539) so each response row carries the key the merge step needs. Rows from every chunk are merged into one dict keyed by repr((pivotValues or pivotValue, dateRange)) (542-544, dict.update per chunk - a metric present in only one chunk's response still lands on the merged row), then each merged row is passed through _normalize_analytics_row (220-250): computed ctr = clicks/impressions*100 and cpc = spend/clicks, both None when the denominator is zero, never a ZeroDivisionError.

DMP hashed-email sync (640-688): every email is run through hash_user_identifier (168-179: None on falsy input, else .lower().strip() then SHA256 hex) before anything is batched; a None result increments skipped instead of raising, so a bad row degrades the batch's numbers rather than the request (652-659). Hashed digests are batched at DMP_BATCH_SIZE = 10_000 (57, the module comment notes LinkedIn's own append cap is higher and this deliberately matches the Meta audience-sync rhythm) and each batch is one POST .../dmpSegments/{id}/users with X-RestLi-Method: BATCH_CREATE and userIds: [{"idType": "SHA256_EMAIL", "idValue": <digest>}] per element (662-682). An action outside {"ADD", "REMOVE"} raises ValueError before any HTTP call (649-650).

DMP company/ABM sync (696-753): _company_id_entry (182-198) classifies each string - a literal urn:li:organization: prefix stays an ORGANIZATION_URN; a value containing . and no space is normalized to a bare lowercase domain (strips http(s):// and www., then anything after the first /) and becomes COMPANY_WEBSITE_DOMAIN; anything else is COMPANY_NAME verbatim. Before any classification runs, add_companies_to_dmp_segment scans every entry for "@" and raises ValueError for the entire call on the first hit (711-724) - this is checked ahead of, and independent from, the ADD/REMOVE action validation and the batching loop, so a single bad entry anywhere in the list means nothing in the list is uploaded, not just that entry.

Invariants

  • Every campaign group and campaign this recipe creates is status: "PAUSED", in both callers, with no parameter that overrides it (CampaignGroupCreate.to_linkedin 167, CampaignCreate.to_linkedin 251, linkedin_adapter.py 127; test_forced_paused_and_account_urn, test_forced_paused_and_urns).
  • An add_companies_to_dmp_segment call containing any email-like entry uploads nothing at all - not even the clean entries before it in the list (services/linkedin_ads_service.py 718-723; test_email_hard_guard_refuses_whole_call asserts fake_httpx.calls == []).
  • A raw email is never sent to LinkedIn; only its SHA256 hex digest is (hash_user_identifier; test_add_users_hashes_and_batch_creates asserts the uploaded idValue equals the hash, never the input string).
  • No adAnalytics request ever asks for more than 20 fields, and every chunk still carries both merge keys (_chunk_fields reserving 2 of 20; test_analytics_chunks_fields_and_merges_rows asserts len(field_list) <= 20 and both keys present on every one of the two chunked requests).
  • A 401 is retried at most once, and only when a refresh_token was supplied; a second 401 after the refresh is raised, not retried again (_request's _retried flag, 320, 346, 363; test_401_after_refresh_does_not_loop asserts the fake response queue is fully consumed at exactly three responses - no fourth call is made).
  • _extract_created_id always returns the trailing id segment, never a full URN, regardless of whether LinkedIn answered with a bare id, a URN, or a percent-encoded URN (test_x_restli_id_plain, test_x_restli_id_urn, test_x_restli_id_encoded_urn).

Frontend

ads/ui/ads-manager    AdsManager.tsx + adsService.ts (alchemy_labs/frontend/src/pages/AdsManager.tsx,
                       alchemy_labs/frontend/src/services/adsService.ts). Calls, directly through
                       adsService.ts: listLinkedInAccounts -> GET /api/ads/linkedin/accounts,
                       listLinkedInCampaigns -> GET .../accounts/{id}/campaigns, getLinkedInAnalytics ->
                       GET .../accounts/{id}/analytics (adsService.ts 407-460). Calls, via the raw `api`
                       client rather than a typed wrapper: handleCreateCampaign posts
                       POST /api/ads/campaigns/setup (AdsManager.tsx 1202-1206), which is the multiplatform
                       orchestrator that drives LinkedInAdapter.create -> create_campaign_group ->
                       create_campaign for LinkedIn specifically; MultiplatformSetups.tsx (a component
                       under this same module) lists/retries past setups via listCampaignSetups /
                       getCampaignSetup / retryCampaignSetup (adsService.ts 86-96). Measured 2026-09-12,
                       grep of frontend/src: no caller anywhere for PATCH/DELETE on a LinkedIn campaign
                       group or campaign, for POST /api/ads/linkedin/audiences*, or for any single one
                       of the ten /api/admin/audiences/linkedin/* routes (the plain segment-sync block
                       AND the company/ABM block) - the one page that calls that router at all,
                       AdminAudienceSync.tsx (mounted at /admin/audiences), calls only its /meta/* routes
                       (lines 40-81); every DMP-segment and company/ABM endpoint is reachable only by a
                       direct API call, not from any shipped UI.

Minimum admin/debug surface: no dedicated route exists for inspecting DMP segment membership size; the router only surfaces LinkedIn's own warning string on create/add (LINKEDIN_SEGMENT_SIZE_WARNING, audience_sync_router.py 164-168: "LinkedIn requires ~300 matched members before a segment becomes usable for targeting... this is expected, not an error", LINKEDIN_MIN_SEGMENT_SIZE = 300).

Security & tenancy

Auth dependency: CurrentUser/get_current_user (S5) on every route in both routers; linkedin_ads_router.py additionally requires _require_workspace(request) (S6, 400 if the X-Workspace-ID header/state is absent) and audience_sync_router.py requires the typed WorkspaceID dependency plus _require_admin (user.is_admin, 403 otherwise) on every one of its routes - the DMP-segment admin surface is admin-only across the board, unlike the read/CRUD surface on linkedin_ads_router.py which only needs a signed-in workspace member. Tenancy: the OAuth token pair and ad-account id are read from WorkspaceSettings.settings["tracking"] for the calling workspace_id (_auth_context/_get_tracking_settings, linkedin_ads_router.py 89-102, 232-243; _linkedin_auth/_linkedin_account_id, audience_sync_router.py 207-243, with an env-var fallback LINKEDIN_ACCESS_TOKEN/LINKEDIN_AD_ACCOUNT_ID when the workspace has no connection, core/config.py 991-992 - the env path cannot rotate tokens, only the workspace path passes a save_tokens callback).

Fail direction: _gate_write (linkedin_ads_router.py) is closed only when scopes are known to lack rw_ads; an unknown-scope legacy token is let through and LinkedIn's own 403 is surfaced - an intentional open-on-unknown choice, not a bug, documented in the module's own comment (29-31, linkedin_ads_router.py 8-15). The email hard-guard on the company path is closed unconditionally, no flag disables it. LINKEDIN_ABM_ENABLED fails closed to a bare 404 (not 403 - _require_abm deliberately hides that the feature exists at all when off, audience_sync_router.py 384-387).

Secrets and PII at rest: the LinkedIn access/refresh token is encrypted with encrypt_token before the WorkspaceSettings.settings JSON column is written, both save paths (linkedin_ads_router.py 72-74; audience_sync_router.py 522-524); a raw email is never persisted or logged by this recipe - the log lines at 679-682 and 744-747 print only the batch index and count, never a value. Attacker-controlled inputs: every free-text company entry in add_companies_to_dmp_segment (guarded against @, otherwise passed through _company_id_entry's classification, itself only string manipulation, no eval/format-string risk); every email in add_users_to_dmp_segment (hashed before use, never reflected); campaign_ids/fields query params on the analytics route (comma-split, no further validation, fed into a RestLi List(...) literal via _restli_list, which percent-encodes every element with quote(str(item), safe="") - a RestLi-syntax character in an id cannot escape the List(...) wrapper).

Tests

Run 2026-09-12 from alchemy_labs/backend with the repo's own .venv:

./venv/bin/python -m pytest tests/test_linkedin_ads.py -q --no-cov -o addopts="" -p no:cacheprovider

prints 76 passed (0 failed; 32 Pydantic-deprecation warnings unrelated to this recipe, from core/billing_config.py's use of the Pydantic v1-style Field(..., env=...) kwarg). Hermetic: no network and no database - httpx.AsyncClient is replaced module-wide by FakeAsyncClient, a minimal stand-in with a shared queue/calls list (tests/test_linkedin_ads.py 353-384), so every response is scripted and every outbound call is inspectable afterward. grep -c "^ def test_" tests/test_linkedin_ads.py prints 76 across 18 class Test* groups (grep -c "^class Test" prints 18), and grep -c parametrize tests/test_linkedin_ads.py prints 0 - the 76 collected tests equal the 76 def test_ bodies exactly, unlike the billing-credits recipe's suite.

Known-answer vector: test_known_sha256 asserts hash_user_identifier("[email protected]") equals the literal hex digest 973dfe463ec85785f5f95af5ba3906eedb2d931c24e69824a89ea65dba4e813b (tests/test_linkedin_ads.py 144-148) - independently recomputed with hashlib.sha256(b"[email protected]").hexdigest() and confirmed to match exactly (verified 2026-09-12).

Coverage this suite does NOT reach: no test exercises linkedin_ads_router.py or audience_sync_router.py directly (no TestClient/FastAPI integration test for either router was found anywhere under tests/) - _gate_write, _require_admin, _require_abm, and the 401/403/404/502 HTTP-status mapping in _raise_http are therefore unverified by an automated test at 62ba4fc, only by inspection of the source above; no Playwright/E2E spec exists for the UI paths under Frontend.

Deploy & ops

Config keys: LINKEDIN_CLIENT_ID, LINKEDIN_CLIENT_SECRET, LINKEDIN_ABM_ENABLED (all in core/config.py, cited above); LINKEDIN_ACCESS_TOKEN/LINKEDIN_AD_ACCOUNT_ID as the env-var fallback for workspaces with no stored connection. No migration: this recipe adds no table. No scheduler job: every call in this recipe is request-triggered, nothing runs on a timer. Rollback: nothing stateful to unwind on Labs' side - a rolled-back deploy leaves LinkedIn's own campaign/segment entities exactly as they were (they are LinkedIn's system of record, not Labs').

Argus-style checks: GET /api/ads/linkedin/accounts with a connected workspace token returns 200 with a non-empty accounts list in steady state (400 if the workspace has no LinkedIn connection at all, per _auth_context); a POST /api/ads/linkedin/audiences/{segment_id}/users with a mixed valid/invalid email list returns skipped > 0 and uploaded equal to the valid count, never a 5xx, proving the hash/skip path degrades rather than fails.

Gotchas

  • 2026-09-12 (measured at 62ba4fc): the DMP hashed-email audience endpoints (POST /api/ads/linkedin/audiences, POST /api/ads/linkedin/audiences/{id}/users) and all ten routes under /api/admin/audiences/linkedin/* - both the plain segment-sync block (249-369) and the company/ABM block (372-580) - have no caller anywhere in frontend/src (grep; the one page mounted on that router, AdminAudienceSync.tsx, calls only its /meta/* siblings). They are real, mounted, unit-tested code, not scaffolds - but nothing in the shipped app has ever driven them outside a test. A rebuild should treat this half of the recipe as unexercised in production regardless of the green suite, the same caveat billing-credits/auto-topup-charge-and-retry makes about its own webhook sender.
  • 2026-09-12 (measured at 62ba4fc, audience_sync_router.py 561-580): pause_campaign's own docstring says it is "used by the Aurum warm-up timer at warm-end" (568). No such timer, scheduled job, or any caller of this route exists anywhere in the repo - grep -rln "warm-up\|warm_up" backend --include=*.py (excluding this router, its own service module, and its own test file) returns nothing, and grep -n "pause_campaign" services/scheduled_jobs.py returns nothing. Whatever "Aurum" system the comment refers to (api/routers/aurum_router.py, signal/key minting, unrelated to ad campaigns) does not call it either. Treat the docstring's claim as aspirational, not measured.
  • 2026-09-12 (measured at 62ba4fc, core/config.py 958): LINKEDIN_ABM_ENABLED defaults to False. Until it is flipped per-deployment, every route under audience_sync_router.py's /linkedin/company/* and /linkedin/campaigns/{id}/activate answers a bare 404, indistinguishable from the route not existing - _require_abm is deliberately silent about why.
  • 2026-09-12 (measured at 62ba4fc): two independent code paths build a campaign-group/campaign payload for LinkedIn - the router's typed CampaignGroupCreate/CampaignCreate.to_linkedin() and LinkedInAdapter.build_payload's hand-built dict. They agree on the invariants that matter (forced PAUSED, string budget amounts, campaignGroup URN threading) but are not the same code, so a fix to one (e.g. a new required field LinkedIn starts enforcing) is not automatically a fix to the other.
  • 2026-09-12 (measured at 62ba4fc): _COMPANY_URN_CACHE (services/linkedin_ads_service.py 60-61, 782-784) is a plain module-level dict with no TTL, shared across every workspace calling resolve_company_urns (the module docstring calls this "public data, not workspace-scoped" - a deliberate choice, not an oversight, but worth knowing before assuming per-tenant isolation on this one cache). It is cleared entirely, not LRU-evicted, once it reaches 1024 entries.

Reference implementation

Paths relative to the repo root; all at alchemy_labs main 62ba4fc41009b454e1c033e5036c7e8577db2061 unless noted.

  • alchemy_labs/backend/services/linkedin_ads_service.py (813 lines): module docstring (1-34); RestLi/URN helpers (109-250); LinkedInAdsService (257-813) - OAuth refresh (265-306), _request (310-380), ad accounts (393-400), campaign groups CRUD (404-443), campaigns CRUD (447-493), get_ad_analytics (497-546), Phase-2 scaffolds (554-591), DMP segments (597-688), company/ABM (696-785), targeting/typeahead (789-809).
  • alchemy_labs/backend/services/campaign_setup/adapters/linkedin_adapter.py (203 lines, whole file in scope): the guarded import (31-34); _account_id/validate (42-65); build_payload (67-153); create (155-203).
  • alchemy_labs/backend/schemas/linkedin_ads_schemas.py (381 lines): targeting facet registry and TargetingCriteria.compile() (19-101); Money (108-126); CampaignGroupCreate/Update (141-203); CampaignCreate/Update (210-319); Phase-2 request bodies (326-350); response models (357-382).
  • alchemy_labs/backend/api/routers/linkedin_ads_router.py (564 lines, whole file in scope): module docstring (1-19); auth/scope helpers (53-150); accounts (157-186); campaign groups (193-282); campaigns (289-388); analytics (395-438); Phase-2-labeled routes that actually call real (non-501) service methods for audiences (482-533).
  • alchemy_labs/backend/api/routers/audience_sync_router.py (580 lines): _require_admin (44-47); LinkedIn DMP section (160-370) - LINKEDIN_MIN_SEGMENT_SIZE/LINKEDIN_SEGMENT_SIZE_WARNING (163-168), _linkedin_auth/_linkedin_account_id (207-243), preview/list/create/sync (249-369); LinkedIn company/ABM section (372-580) - _require_abm (384-387), resolve/create/add/campaign/activate/pause (416-580).
  • alchemy_labs/backend/app_fastapi.py: router imports (111-115); mounts (863-868, 967).
  • alchemy_labs/backend/core/config.py: LINKEDIN_ABM_ENABLED (958); LINKEDIN_CLIENT_ID/LINKEDIN_CLIENT_SECRET/LINKEDIN_ACCESS_TOKEN/LINKEDIN_AD_ACCOUNT_ID (989-992).
  • alchemy_labs/frontend/src/services/adsService.ts: LinkedIn section (407-460); campaign-setup list/get/retry (86-96).
  • alchemy_labs/frontend/src/pages/AdsManager.tsx: handleCreateCampaign (starts line 1202, posts to /api/ads/campaigns/setup at 1205).
  • Tests: alchemy_labs/backend/tests/test_linkedin_ads.py (774 lines, whole file in scope, 76 def test_ across 18 classes).

Deviations of the reference from an ideal implementation, dated 2026-09-12: no integration test exercises either router directly (Tests); the DMP-segment and company/ABM endpoints have no production caller (Gotchas); the AudienceCreate schema's docstring mentions COMPANY_LIST_UPLOAD as an example segment type but no code path ever sends that literal string, only "USER" or "COMPANY"; the campaign-group/campaign payload is built by two independent, not-shared implementations (Gotchas); the _COMPANY_URN_CACHE is unbounded-per-key and shared across workspaces (Gotchas).

Acceptance checks

Run from alchemy_labs/backend at 62ba4fc. Expected observations are exact, as measured 2026-09-12.

  1. ./venv/bin/python -m pytest tests/test_linkedin_ads.py -q --no-cov -o addopts="" -p no:cacheprovider prints 76 passed.
  2. grep -c "^ def test_" tests/test_linkedin_ads.py prints 76; grep -c "^class Test" tests/test_linkedin_ads.py prints 18; grep -c parametrize tests/test_linkedin_ads.py prints 0.
  3. grep -n "status.*PAUSED" schemas/linkedin_ads_schemas.py shows both CampaignGroupCreate.to_linkedin (line 167) and CampaignCreate.to_linkedin (line 251) hard-coding the literal, with no parameter in either class that can override it.
  4. grep -n "if \"@\" in value" services/linkedin_ads_service.py prints line 718, inside add_companies_to_dmp_segment, ahead of the batching loop that starts at line 727.
  5. grep -n "LINKEDIN_ABM_ENABLED" core/config.py shows default=False on line 958.
  6. grep -rn "linkedin/audiences\|linkedin/company\|admin/audiences/linkedin" ../frontend/src (run from backend/) returns no match (exit code 1), confirming the Frontend section's "no shipped caller" claim for the DMP-segment and company/ABM routes.
  7. python3 -c "import hashlib; print(hashlib.sha256(b'[email protected]').hexdigest())" prints 973dfe463ec85785f5f95af5ba3906eedb2d931c24e69824a89ea65dba4e813b, matching the literal test_known_sha256 asserts (tests/test_linkedin_ads.py 146-148) exactly.
  8. grep -n "pause_campaign" services/scheduled_jobs.py prints nothing, and grep -rln "warm-up\|warm_up" --include=*.py . lists only core/config.py, tests/test_linkedin_ads.py, api/routers/audience_sync_router.py and services/linkedin_ads_service.py - no scheduler or job file, confirming the Gotchas claim that pause_campaign's documented "Aurum warm-up timer" caller does not exist in this repo.

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.