# LOCK 10 — Apple Search Ads POST /campaigns Investigation (/docs/internal/apple-campaign-create-investigation)



# LOCK 10 — Apple Search Ads `POST /campaigns` Investigation [#lock-10--apple-search-ads-post-campaigns-investigation]

**Status:** Internal scratchpad. Not for partner consumption.

## 1. Verdict [#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 [#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`):
  ```json
  {
    "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 [#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 /campaigns` → `apple_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 [#4-implementation-path]

### Authority gate edits (mirror both files) [#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 [#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 [#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) [#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.
