Layers
Internal

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

View as Markdown

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 uses v5 per temporal/src/services/ads/apple-api-client.ts:56-57; reference clients on v4 confirm the same path shape).
  • Auth: Authorization: Bearer <access_token> plus mandatory X-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 by phiture/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" }
    }
    budgetAmount is the lifetime cap (optional); dailyBudgetAmount is 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:write covers create + update. Layers requests it on every Apple connect — see api/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 both campaigns:read and campaigns:write are sent during the appleid.apple.com/auth/authorize flow.
  • Internal client wiring (transport): temporal/src/services/ads/apple-api-client.ts deliberately omits a campaign-create helper — see lines 32-36 ("No campaign-level write helpers here") and lines 484-494 (header comment on setCampaignStatus). 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-179 is a fully wired OpenCode tool that calls appleApiWrite with endpoint: '/campaigns', method: 'POST', operation: 'apple_create_campaign'. Its header comment (lines 6-13) describes a preset-conditional trap: END_TO_END writer preset allows; other presets deny with apple_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_campaign is in ALLOWED_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 at agents/apple-ads-agent/scripts/apple-api-helper.mjs:107-113 maps POST /campaignsapple_create_campaign.
  • Authority taxonomy: apple_create_campaign is bucketed as structural in 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 unconditional if block at step 4b short-circuits.
  • The unconditional deny lives in two mirrored places:
    • temporal/src/services/ads/authority-gate.ts:540-546
    • api/lib/ads/authority-gate.ts:411-417 Both carry the same incorrect rationale. The schema doc at packages/shared-types/src/http/paid-media/campaign-authority.ts:165 repeats 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) and packages/shared-types/src/http/paid-media/campaign-authority.test.ts:48.

4. Implementation path

Authority gate edits (mirror both files)

  1. Delete the step-4b block.
    • temporal/src/services/ads/authority-gate.ts:534-546 — remove the if (platform === 'apple' && op === 'apple_create_campaign') guard. The bucket-mode flow already places apple_create_campaign in structural (line 233), so it inherits the standard off / draft / auto ladder. Update the comment block at lines 534-539 accordingly.
    • api/lib/ads/authority-gate.ts:408-417 — same delete; identical mirror.
  2. Update the schema rationale. packages/shared-types/src/http/paid-media/campaign-authority.ts:165 — drop "but apple_create_campaign is always denied at the gate"; replace with the preset-conditional framing already used by agents/ads-management-agent/src/services/apple-api.ts:39-43.
  3. 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 with bucket_off on 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).
  4. Cleanup-exempt set: CLEANUP_EXEMPT_ACTIVITIES at temporal/src/services/ads/authority-gate.ts:335-340 and api/lib/ads/authority-gate.ts:248-253 need no change — apple_create_campaign is 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 at api/app/api/partner/v1/projects/[projectId]/ads/campaigns/route.ts.
  • Body schema (add to packages/shared-types/src/http/partner/ads/schema.ts): AppleCampaignCreateRequestSchema with name, adamId (number), countriesOrRegions (string[]), dailyBudget (string, currency-major), optional lifetimeBudget (string), optional currency (default USD), optional status (enum ENABLED | PAUSED, default PAUSED), 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 via resolveAuthorityDecision({ platform: 'apple', operation: 'apple_create_campaign', layerId, actor: { type: 'user', userId } }) → on allow, POST to /api/v5/campaigns using the same auth pattern as forwardToApple in api/app/api/internal/paid-media/apple/proxy/route.ts:176-205 → on success, return { campaignId, name, status, ... } (shape: extend AppleCampaignPublicSchema).
  • Pre-existing internal proxy already allows it. No changes needed at api/app/api/internal/paid-media/apple/proxy/route.ts beyond what already lands apple_create_campaign in ALLOWED_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_campaigns with project_id, layer_id, campaign_id, apple_ads_org_id, campaign_name, status so the new campaign appears in the cross-platform list at api/app/api/partner/v1/projects/[projectId]/ads/campaigns/route.ts:265-300 immediately, instead of waiting for the next status sync.

Tests / verification

  • Unit: extend temporal/src/services/ads/authority-gate.test.ts to cover apple_create_campaign on every bucket mode (allow on auto, queue on draft, deny bucket_off on off).
  • Parity: temporal/src/services/ads/authority-gate-parity.test.ts re-runs synthetic ops through both temporal/ and api/ gate copies — must pass after the mirror edits.
  • Integration: add a test for the new partner route mocking the Apple /campaigns POST and asserting the row appears in the campaigns list.
  • Smoke: agents/apple-ads-agent/.opencode/tool/create_campaign.ts already 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 the END_TO_END writer preset can create new campaigns; layers on adgroup-grain presets (the default for most install-tracking layers) deny campaign creation with apple_adgroup_grain_violation and require an explicit human-in-the-loop step. Daily budget caps and lifetime caps are honoured exactly as supplied; status defaults to PAUSED so partners can stage a campaign before activating it.

On this page