LOCK 10 — Apple Search Ads POST /campaigns Investigation
Internal scratchpad documenting why the unconditional apple_create_campaign deny was lifted (Layers product policy, not an Apple platform limitation).
LOCK 10 — Apple Search Ads POST /campaigns Investigation
Status: Internal scratchpad. Not for partner consumption.
1. Verdict
Yes — Apple Search Ads supports programmatic campaign creation, and Layers can lift the unconditional deny. The current gate comment at temporal/src/services/ads/authority-gate.ts:534-545 and api/lib/ads/authority-gate.ts:408-417 (Apple's API does not support campaign-level automation) is factually wrong. Apple Search Ads Campaign Management API exposes POST https://api.searchads.apple.com/api/v5/campaigns, and Layers itself already calls that endpoint from agents/apple-ads-agent/.opencode/tool/create_campaign.ts. The current state is a Layers product policy enforced by an outdated bypass, not an Apple platform limitation.
2. Apple API findings
WebFetch is blocked by Apple's developer documentation (developer.apple.com returns 404 for direct API-doc paths). Confirmation comes from authoritative third-party clients indexed via GitHub code search and from Layers' own production-shaped helper.
- Endpoint:
POST https://api.searchads.apple.com/api/v5/campaigns(Layers usesv5pertemporal/src/services/ads/apple-api-client.ts:56-57; reference clients on v4 confirm the same path shape). - Auth:
Authorization: Bearer <access_token>plus mandatoryX-AP-Context: orgId=<org_id>. Same auth Layers already uses on every other Apple write (temporal/src/services/ads/apple-api-client.ts:333-338,api/app/api/internal/paid-media/apple/proxy/route.ts:185-195). - Required body fields (from
agents/apple-ads-agent/.opencode/tool/create_campaign.ts:68-91, corroborated byphiture/searchads_api,rkotzy/SearchAdsCLI):{ "name": "string", "adamId": 123456789, "orgId": 0, "countriesOrRegions": ["US"], "adChannelType": "SEARCH", "supplySources": ["APPSTORE_SEARCH_RESULTS"], "billingEvent": "TAPS", "status": "ENABLED" | "PAUSED", "budgetAmount": { "amount": "10000.00", "currency": "USD" }, "dailyBudgetAmount": { "amount": "100.00", "currency": "USD" } }budgetAmountis the lifetime cap (optional);dailyBudgetAmountis the daily pacing cap; either or both may be set. - Response:
{ data: { id: <campaignId>, ... }, pagination, error }— same envelope shape as the rest of/api/v5/*. - OAuth scope:
campaigns:writecovers create + update. Layers requests it on every Apple connect — seeapi/app/api/partner/v1/projects/[projectId]/ads/ad-accounts/oauth-url/route.ts:47(const APPLE_ADS_SCOPES = ["campaigns:read", "campaigns:write"].join(" ");).
3. Layers state
The contradiction is well-mapped in code already; the unconditional deny is the only thing that actually blocks the call.
- Scope already requested:
api/app/api/partner/v1/projects/[projectId]/ads/ad-accounts/oauth-url/route.ts:47. Confirmed bothcampaigns:readandcampaigns:writeare sent during theappleid.apple.com/auth/authorizeflow. - Internal client wiring (transport):
temporal/src/services/ads/apple-api-client.tsdeliberately omits a campaign-create helper — see lines 32-36 ("No campaign-level write helpers here") and lines 484-494 (header comment onsetCampaignStatus).updateCampaign(lines 525-545) exists only as a backwards-compat shim for cleanup paths. - Agent already creates campaigns:
agents/apple-ads-agent/.opencode/tool/create_campaign.ts:1-179is a fully wired OpenCode tool that callsappleApiWritewithendpoint: '/campaigns',method: 'POST',operation: 'apple_create_campaign'. Its header comment (lines 6-13) describes a preset-conditional trap:END_TO_ENDwriter preset allows; other presets deny withapple_adgroup_grain_violation. That tool's contract is what the gate currently violates. - Proxy allow-list:
api/app/api/internal/paid-media/apple/proxy/route.ts:65-99.apple_create_campaignis inALLOWED_OPERATIONS(line 71) precisely so the gate can issue the architectural deny rather than the proxy 400ing with "operation not recognized" (lines 66-70 comment). Endpoint inference atagents/apple-ads-agent/scripts/apple-api-helper.mjs:107-113mapsPOST /campaigns→apple_create_campaign. - Authority taxonomy:
apple_create_campaignis bucketed asstructuralin both gate files (temporal/src/services/ads/authority-gate.ts:233,api/lib/ads/authority-gate.ts:166). The bucket model is set up to gate it like any other structural op — only the unconditionalifblock at step 4b short-circuits. - The unconditional deny lives in two mirrored places:
temporal/src/services/ads/authority-gate.ts:540-546api/lib/ads/authority-gate.ts:411-417Both carry the same incorrect rationale. The schema doc atpackages/shared-types/src/http/paid-media/campaign-authority.ts:165repeats the same line.
- Tests pinning the deny:
temporal/src/services/ads/authority-gate.test.ts:397-410(step 4b — apple_create_campaign defense-in-depth) andpackages/shared-types/src/http/paid-media/campaign-authority.test.ts:48.
4. Implementation path
Authority gate edits (mirror both files)
- Delete the step-4b block.
temporal/src/services/ads/authority-gate.ts:534-546— remove theif (platform === 'apple' && op === 'apple_create_campaign')guard. The bucket-mode flow already placesapple_create_campaigninstructural(line 233), so it inherits the standardoff/draft/autoladder. Update the comment block at lines 534-539 accordingly.api/lib/ads/authority-gate.ts:408-417— same delete; identical mirror.
- Update the schema rationale.
packages/shared-types/src/http/paid-media/campaign-authority.ts:165— drop "butapple_create_campaignis always denied at the gate"; replace with the preset-conditional framing already used byagents/ads-management-agent/src/services/apple-api.ts:39-43. - Update tests.
temporal/src/services/ads/authority-gate.test.ts:397-410— flip the assertion to "allowed when structural=auto on END_TO_END preset, denied withbucket_offon structural=off". Add a case for the preset-conditional trap if/when preset-2.5 logic lands (currently the gate is bucket-only; preset-aware logic referenced by the agent tool's header comment is not yet in the gate body, so until then any structural=auto suffices). - Cleanup-exempt set:
CLEANUP_EXEMPT_ACTIVITIESattemporal/src/services/ads/authority-gate.ts:335-340andapi/lib/ads/authority-gate.ts:248-253need no change —apple_create_campaignis not a cleanup op.
Partner endpoint
Create api/app/api/partner/v1/projects/[projectId]/ads/apple/campaigns/route.ts (POST):
- Wrapper:
withPartnerProjectAccess,endpointClass: "write",requiredScope: "ads:write". Mirror the existing list endpoint atapi/app/api/partner/v1/projects/[projectId]/ads/campaigns/route.ts. - Body schema (add to
packages/shared-types/src/http/partner/ads/schema.ts):AppleCampaignCreateRequestSchemawithname,adamId(number),countriesOrRegions(string[]),dailyBudget(string, currency-major), optionallifetimeBudget(string), optionalcurrency(defaultUSD), optionalstatus(enumENABLED|PAUSED, defaultPAUSED),layerId(uuid — required to resolve the authority for the gate). - Flow: validate body → resolve credentials via
resolveAppleAdsCredentials(already used by the proxy) → call the gate viaresolveAuthorityDecision({ platform: 'apple', operation: 'apple_create_campaign', layerId, actor: { type: 'user', userId } })→ onallow, POST to/api/v5/campaignsusing the same auth pattern asforwardToAppleinapi/app/api/internal/paid-media/apple/proxy/route.ts:176-205→ on success, return{ campaignId, name, status, ... }(shape: extendAppleCampaignPublicSchema). - Pre-existing internal proxy already allows it. No changes needed at
api/app/api/internal/paid-media/apple/proxy/route.tsbeyond what already landsapple_create_campaigninALLOWED_OPERATIONS(line 71). The partner endpoint can either go via the proxy (using a system-actor service token) or call Apple directly using the project-scoped credentials. - Persist the row: insert into
apple_ads_campaignswithproject_id,layer_id,campaign_id,apple_ads_org_id,campaign_name,statusso the new campaign appears in the cross-platform list atapi/app/api/partner/v1/projects/[projectId]/ads/campaigns/route.ts:265-300immediately, instead of waiting for the next status sync.
Tests / verification
- Unit: extend
temporal/src/services/ads/authority-gate.test.tsto coverapple_create_campaignon every bucket mode (allow on auto, queue on draft, denybucket_offon off). - Parity:
temporal/src/services/ads/authority-gate-parity.test.tsre-runs synthetic ops through bothtemporal/andapi/gate copies — must pass after the mirror edits. - Integration: add a test for the new partner route mocking the Apple
/campaignsPOST and asserting the row appears in the campaigns list. - Smoke:
agents/apple-ads-agent/.opencode/tool/create_campaign.tsalready round-trips the POST via the proxy in dev — flipping the gate is enough to clear the only path that currently denies legitimate END_TO_END campaign creation.
5. (Conditional caveats — partner-facing concept paragraph)
If we ship campaign-create publicly without the preset-conditional logic the agent tool assumes, document the caveat:
Apple Search Ads campaign creation is supported through the Layers Partner API (
POST /v1/projects/{projectId}/ads/apple/campaigns). Apple's billing model treats a campaign as the spend envelope for one app in one region with one supply source — once created, ongoing optimization happens at the adgroup grain (bids, budgets, keywords) rather than on the campaign itself. To prevent automated agents from silently widening a customer's spend envelope, only layers configured with theEND_TO_ENDwriter preset can create new campaigns; layers on adgroup-grain presets (the default for most install-tracking layers) deny campaign creation withapple_adgroup_grain_violationand require an explicit human-in-the-loop step. Daily budget caps and lifetime caps are honoured exactly as supplied; status defaults toPAUSEDso partners can stage a campaign before activating it.