Duplicate & Lifecycle
Duplicate, pause, resume, delete, and dry-run a campaign before you spend
Duplicate & Lifecycle
Everything that changes the state of an existing hierarchy rather than its settings: copying it, starting and stopping delivery, deleting it, and validating a create before it costs anything.
Dry-run a create
validateOnly: true on POST /v1/ads/create runs every node through Meta's own execution_options: validate_only. Nothing is created on Meta and nothing is persisted in Zernio. You get 200 with per-node results instead of an ad.
const { data } = await zernio.adcampaigns.createStandaloneAd({
body: {
accountId: 'acc_metaads_123',
adAccountId: 'act_1234567890',
name: 'Spring sale (dry run)',
goal: 'conversions',
budgetAmount: 50,
budgetType: 'daily',
headline: 'Spring sale',
body: '20% off everything',
callToAction: 'SHOP_NOW',
linkUrl: 'https://example.com',
imageUrl: 'https://cdn.example.com/banner.jpg',
promotedObject: { pixelId: '1729525464415281', customEventType: 'PURCHASE' },
validateOnly: true,
},
});
// data.validateOnly === true, data.results[] carries one entry per nodedata = client.ad_campaigns.create_standalone_ad(
account_id="acc_metaads_123",
ad_account_id="act_1234567890",
name="Spring sale (dry run)",
goal="conversions",
budget_amount=50,
budget_type="daily",
headline="Spring sale",
body="20% off everything",
call_to_action="SHOP_NOW",
link_url="https://example.com",
image_url="https://cdn.example.com/banner.jpg",
promoted_object={"pixelId": "1729525464415281", "customEventType": "PURCHASE"},
validate_only=True,
)curl -X POST "https://zernio.com/api/v1/ads/create" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"accountId": "acc_metaads_123",
"adAccountId": "act_1234567890",
"name": "Spring sale (dry run)",
"goal": "conversions",
"budgetAmount": 50,
"budgetType": "daily",
"headline": "Spring sale",
"body": "20% off everything",
"callToAction": "SHOP_NOW",
"linkUrl": "https://example.com",
"imageUrl": "https://cdn.example.com/banner.jpg",
"promotedObject": { "pixelId": "1729525464415281", "customEventType": "PURCHASE" },
"validateOnly": true
}'A Meta validation failure comes back as its own 400, verbatim. That is the point: Meta's validator catches real configuration errors a schema can't, for example budget sharing conflicts, a CBO campaign whose ad sets share an optimization goal, or a landing page Meta refuses.
Meta can only validate a child against a real parent. On a fresh tree, the campaign and the creative are validated and the ad set reports skipped. Pass existingCampaignId to validate the ad set against that real campaign too. The ad itself is never validatable before create.
Two things to know. validateOnly is only supported on the single standalone shape: combining it with creatives[], an adSetId attach, or buyingType: "RESERVED" returns a 400, and non-Meta platforms return 501. And media referenced by imageUrl is uploaded to the account's image library during the dry run, because Meta needs a real image hash to validate the creative. Image uploads are content-deduped, so repeated dry runs are harmless.
Pause / resume
Single campaign, cascades to all its ad sets and ads:
await zernio.adcampaigns.updateAdCampaignStatus({
path: { campaignId: 'CAMPAIGN_ID' },
body: { status: 'paused', platform: 'facebook' },
});client.ad_campaigns.update_ad_campaign_status(
campaign_id="CAMPAIGN_ID",
status="paused",
platform="facebook",
)curl -X PUT "https://zernio.com/api/v1/ads/campaigns/CAMPAIGN_ID/status" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "status": "paused", "platform": "facebook" }'Bulk (up to 50 campaigns). Each row's result is reported independently so one failure doesn't fail the whole batch:
await zernio.adcampaigns.bulkUpdateAdCampaignStatus({
body: {
status: 'paused',
campaigns: [
{ platformCampaignId: '1202...', platform: 'facebook' },
{ platformCampaignId: '1203...', platform: 'facebook' },
],
},
});client.ad_campaigns.bulk_update_ad_campaign_status(
status="paused",
campaigns=[
{"platformCampaignId": "1202...", "platform": "facebook"},
{"platformCampaignId": "1203...", "platform": "facebook"},
],
)curl -X POST "https://zernio.com/api/v1/ads/campaigns/bulk-status" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"status": "paused",
"campaigns": [
{ "platformCampaignId": "1202...", "platform": "facebook" },
{ "platformCampaignId": "1203...", "platform": "facebook" }
]
}'Narrower scopes: PUT /v1/ads/ad-sets/{adSetId}/status for one ad set, PUT /v1/ads/{adId}/status for one ad.
Duplicate a campaign
POST /v1/ads/campaigns/{campaignId}/duplicate wraps Meta's native POST /{campaign-id}/copies. It defaults to deepCopy: true + statusOption: "PAUSED" so the copy arrives with the full hierarchy but doesn't start delivering until you activate it. Zernio triggers a discovery sync after the copy so the new hierarchy materializes in /ads/tree within seconds (set syncAfter: false to skip).
const result = await zernio.adcampaigns.duplicateAdCampaign({
path: { campaignId: 'CAMPAIGN_ID' },
body: {
platform: 'facebook',
deepCopy: true,
statusOption: 'PAUSED',
renameStrategy: 'DEEP_RENAME',
renameSuffix: ' (copy)',
},
});result = client.ad_campaigns.duplicate_ad_campaign(
campaign_id="CAMPAIGN_ID",
platform="facebook",
deep_copy=True,
status_option="PAUSED",
rename_strategy="DEEP_RENAME",
rename_suffix=" (copy)",
)curl -X POST "https://zernio.com/api/v1/ads/campaigns/CAMPAIGN_ID/duplicate" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"platform": "facebook",
"deepCopy": true,
"statusOption": "PAUSED",
"renameStrategy": "DEEP_RENAME",
"renameSuffix": " (copy)"
}'Returns { copiedCampaignId, discovery, raw }.
Duplicate an ad set or an ad
Same shape, one and two levels down. Both use Meta's native /copies, both create the copy PAUSED by default, and both accept syncAfter: false.
POST /v1/ads/ad-sets/{adSetId}/duplicate takes an optional campaignId to retarget the copy into another campaign, plus deepCopy (default true, so ads and creatives come along), statusOption, startTime / endTime, and the rename options. Returns { copiedAdSetId }.
POST /v1/ads/{adId}/duplicate takes an optional adSetId to retarget the copy into another ad set, and accepts either the Zernio ad id or the Meta ad id. Returns { copiedAdId }.
// Move a copy of an ad set into a different campaign, ads included
const { data } = await zernio.adcampaigns.duplicateAdSet({
path: { adSetId: 'ADSET_ID' },
body: { platform: 'facebook', campaignId: '120250000000000000', statusOption: 'PAUSED' },
});
// Re-run one creative under a different ad set
const { data: ad } = await zernio.adcampaigns.duplicateAd({
path: { adId: 'AD_ID' },
body: { platform: 'facebook', adSetId: '120250000000000001' },
});data = client.ad_campaigns.duplicate_ad_set(
ad_set_id="ADSET_ID",
platform="facebook",
campaign_id="120250000000000000",
status_option="PAUSED",
)
ad = client.ad_campaigns.duplicate_ad(
ad_id="AD_ID",
platform="facebook",
ad_set_id="120250000000000001",
)curl -X POST "https://zernio.com/api/v1/ads/ad-sets/ADSET_ID/duplicate" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "platform": "facebook", "campaignId": "120250000000000000", "statusOption": "PAUSED" }'
curl -X POST "https://zernio.com/api/v1/ads/AD_ID/duplicate" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "platform": "facebook", "adSetId": "120250000000000001" }'Meta caps a synchronous copy at fewer than 3 total objects. Even a one-ad ad set can trip it, and the count appears to include children deleted by earlier copies. Bigger hierarchies need Meta's async batch mode, so duplicate the campaign instead. The limit surfaces as Meta's own 400, verbatim.
Legacy creatives still carrying Meta's deprecated standard_enhancements field also can't be copied. Rebuild them with creativeFeatures first.
Delete a campaign
DELETE /v1/ads/campaigns/{campaignId} cascades on Meta's side (removing all ad sets and ads) and marks the local Ad documents cancelled in the same pass. The route requires platform in the body so we know which platform's API to call.
await zernio.adcampaigns.deleteAdCampaign({
path: { campaignId: 'CAMPAIGN_ID' },
body: { platform: 'facebook' },
});client.ad_campaigns.delete_ad_campaign(
campaign_id="CAMPAIGN_ID",
platform="facebook",
)curl -X DELETE "https://zernio.com/api/v1/ads/campaigns/CAMPAIGN_ID" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "platform": "facebook" }'DELETE /v1/ads/{adId} removes a single ad and leaves the ad set and campaign in place. To retire an ad without losing its history, pause it instead: deleted objects stop reporting.