Run ads as a partner
End-to-end flow for partner-issued ad writes — connect, import existing campaigns or create one, set authority, trigger optimizer, observe pending queue, approve/reject.
This guide walks the full partner-side ads write path. Every write is gated by the customer's bucket-mode authority — partner keys cannot escalate past customer policy. By the end you'll have a working campaign created on Meta or TikTok, with Layers' optimizer running against it.
Apple Search Ads now supports campaign create from the partner API — see the LOCK 10 investigation for the lift.
Prerequisites
- A project on a Layers org you have a partner key for.
- A partner key with the relevant
ads:write:*sub-scopes (scope vocabulary). For this guide:ads:write:connect,ads:write:campaigns,ads:write:budgets,ads:write:lifecycle,ads:write:optimizer_trigger,ads:write:pending,ads:read. - The customer has consented to partner writes on the project layer (one-time toggle in the customer dashboard).
1. Connect the customer's ad account
OAuth init returns a URL to redirect the customer to. They authorize Meta / TikTok / Apple, then return to your app via returnUrl.
curl -X POST https://api.layers.com/v1/projects/$PROJECT_ID/ads/ad-accounts/oauth-url \
-H "Authorization: Bearer $LAYERS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"platform": "meta_ads",
"returnUrl": "https://partner.example.com/customers/acme/ads/connected"
}'The customer signs in to Meta, picks the ad account they want to use, and redirects to your returnUrl with ?status=connected&accountId=….
When the OAuth flow completes, Layers stores the BYO ad account on the layer. List with GET …/ad-accounts.
2. Import existing campaigns OR create a new one
Existing campaigns
If the customer is already running campaigns on Meta / TikTok and just wants Layers to manage them, no import is needed — GET …/campaigns lists every campaign on the connected ad account, including pre-Layers ones. Subsequent writes (PATCH …/campaigns/:id/budget, …/pause, etc.) flow through the bucket-mode authority gate against whatever per-campaign authority defaults you configure.
To attach Layers' optimizer to an existing campaign, set per-campaign authority:
curl -X PATCH https://api.layers.com/v1/projects/$PROJECT_ID/ads/meta/campaigns/$CAMPAIGN_ID/authority \
-H "Authorization: Bearer $LAYERS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"authority": {
"creative": "auto",
"tactical": "draft",
"structural": "off"
}
}'Create a new campaign (simplified surface)
The headline endpoint takes four required fields. Layers picks every other tactical detail per platform best practice; sophisticated partners can override via the granular CRUD endpoints.
curl -X POST https://api.layers.com/v1/projects/$PROJECT_ID/ads/meta/campaigns \
-H "Authorization: Bearer $LAYERS_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"adAccountId": "aa_01HXF1...",
"name": "Q3 prospecting",
"outcome": "conversions",
"dailyBudgetCents": 5000,
"campaignManagementMode": "draft",
"creativeMode": "autopilot"
}'5000 cents = $50.00 daily budget on every platform. Cents universally — see LOCK 2.
The 202 response carries { jobId, campaignId, verdictId }. Poll jobId until terminal; campaignId is the Layers-side id (the platform-side id is exposed via GET …/campaigns/:id once the workflow lands the real platform mutation).
verdictId is the audit-row UUID — quote it to Layers support if anything goes sideways.
3. Configure the authority block (if needed)
The simplified create endpoint set:
campaignManagementMode: "draft"→tactical: "draft",structural: "draft"creativeMode: "autopilot"→creative: "auto"
If you want a different combination, PATCH the new campaign's authority:
curl -X PATCH https://api.layers.com/v1/projects/$PROJECT_ID/ads/meta/campaigns/$CAMPAIGN_ID/authority \
-H "Authorization: Bearer $LAYERS_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "authority": { "creative": "auto", "tactical": "draft", "structural": "off" } }'That PATCH is itself gated — the customer's layer-defaults policy can refuse it.
4. Trigger the optimizer
The optimizer evaluates 30 days of paid performance and proposes changes (push/replace creatives, budget tweaks, kill underperformers). Trigger it on demand:
curl -X POST https://api.layers.com/v1/projects/$PROJECT_ID/ads/optimizer/run \
-H "Authorization: Bearer $LAYERS_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"layerId": "lyr_8b3c1d2e-4f5a-46b7-9c8d-0e1f2a3b4c5d",
"platform": "meta_ads",
"dryRun": false
}'The 202 returns { jobId, runId }. The full run takes 5–15 minutes. Subscribe to ads.optimizer.run.completed for completion (with humanized change list) or poll GET …/optimizer/runs/:runId.
5. Observe the pending queue
Authority axes set to draft (which the simplified create defaulted tactical to) cause the optimizer's output to queue as pending actions instead of dispatching to the platform.
curl "https://api.layers.com/v1/projects/$PROJECT_ID/ads/meta/pending?status=pending" \
-H "Authorization: Bearer $LAYERS_API_KEY"Each row carries:
actionType—pause_ad,update_budget,push_creative, etc.entityName— the campaign / adset / ad name.currentValue/newValue— what would change.rationale— LLM-summarized explanation.proposedAction.detail— full platform-specific payload (creative urls, targeting deltas).
6. Approve or reject
# Approve
curl -X POST https://api.layers.com/v1/projects/$PROJECT_ID/ads/meta/pending/$PENDING_ID/approve \
-H "Authorization: Bearer $LAYERS_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "comment": "Looks good — spend pacing is on target." }'
# Reject
curl -X POST https://api.layers.com/v1/projects/$PROJECT_ID/ads/meta/pending/$PENDING_ID/reject \
-H "Authorization: Bearer $LAYERS_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "reason": "Customer wants to leave this creative running through Friday." }'Approved actions dispatch on the optimizer's next 6h tick (or earlier if you trigger another run). The dispatchedAt timestamp on the pending-action row populates when the action lands on the platform, and the approval.dispatched webhook fires.
What's already gated
Even with all the right scopes, certain things are by-design unreachable:
- Kill switch — customer-only writable. Partners observe via the audit log +
ads.write.deniedwebhook. - Bucket-mode defaults — partners can PATCH them with
ads:write:policy, but the customer's layer is the source of truth. (See LOCK 1 — parity flavor.)
What's never partner-writable
- Layer-level kill switch. No
ads:write:kill_switchscope exists. - Customer billing / payment methods. Partners do not transact platform spend; the customer's connected ad account is the source of money.