Ads
Ads are a different shape from the rest of this API. Everywhere else you own the records: a post, a queue, a contact all live here. An ad does not. The objects live in the Meta ad account, and zhyper is the thing that creates them, reads them back, and tells you when they change.
Two consequences follow from that, and they explain most of this page:
- What you read is synced, not live. The ad tree and the timeline are built from data a background sync brings in. A campaign you created a moment ago appears there after the next discovery pass, not immediately.
- What you create is real immediately. There is no draft state on Meta's side. This is why creation defaults to paused — see below.
The hierarchy on this page is Meta only: Facebook and Instagram. Campaigns, ad sets, ads, the chain endpoints, the tree and the timeline all serve Meta and reject anything else.
That is not the same as "/v1/ads is Meta", and this page used to say the
stronger thing. Measured 2026-08-31, 99 operations sit under /v1/ads and
eleven of them belong to other platforms:
- Google Ads — Local Services leads and their conversations, keywords, search terms, Keyword Planner ideas and historical metrics, conversion adjustments, and attaching assets to a Search campaign.
- LinkedIn —
POST /v1/ads/targeting/bid-pricingandPOST /v1/ads/targeting/supply-forecast. - TikTok —
GET /v1/ads/business-centers.
The conversion-destination family under /v1/accounts/{accountId}/conversion-destinations
is multi-platform in the same way: a destination is a Google Ads conversion
action or a LinkedIn conversion rule, while a Meta pixel is reached through the
tracking-tags endpoints instead.
None of those follow the campaign model below. They are separate reads and writes against their own platform, and they are described in the API reference.
The shape
ad account
└── campaign objective, status
└── ad set budget, schedule, targeting
└── ad name + creative
Four levels, not five: the creative is a field on the ad, not a level of its own. An ad carries either a creative built from a new post or one that points at an existing post.
Budget lives on the ad set. Campaign-level budgeting (CBO) is not wired on these edges, and it is rejected rather than ignored — sending a campaign budget gets you a 400, not a silently dropped field. Use ABO: each ad set carries its own budget.
Creating a whole hierarchy in one call
Five endpoints build the campaign, ad set, creative and ad together, and they differ only in what kind of ad comes out:
| Endpoint | What it makes |
|---|---|
POST /v1/ads/create | A standalone ad from a new creative |
POST /v1/ads/boost | An ad from a post that already exists |
POST /v1/ads/call | A call ad |
POST /v1/ads/messaging | A messaging ad |
POST /v1/ads/ctwa | A click-to-WhatsApp ad |
Each takes the social accountId (which is how the Meta token is resolved), the
Meta adAccountId (act_<n>), and one campaign, one adSet and an ads
array. They answer 201.
/v1/ads/create, /v1/ads/boost and /v1/ads/call build exactly one ad.
/v1/ads/messaging and /v1/ads/ctwa build up to three in one call —
three creatives and three ads under the same new campaign and ad set. Attaching
to an existing ad set (below) also tops out at three.
Three is small on purpose, and the reason is time rather than taste. The chain
runs the Meta calls one after another, and the API holds a wall-clock ceiling on
how long it will spend at the provider before it stops. Three ads is the largest
request that fits under that ceiling in the worst case, so it is the largest one
we advertise: the number you see in the schema is a number you can actually get,
not one that will time out on you. Ask for a fourth and the request is refused
locally with invalid_ad_count before anything is created on Meta.
Need more than three under one ad set? Send the first three, then use the
existing-ad-set form (adSetId) to add the rest in further calls.
/**
* Dort duzeyin TEK govdede nasil durdugu.
*
* Iki alan sik yanlis yaziliyor, ikisi de burada gorunuyor:
* - `campaign.goal` BIZIM adimizdir (`traffic`, `awareness`,
* `lead_generation`, ...), Meta'nin ODAX sabiti degil; eslemeyi API yapar.
* - Butce AD SET'tedir. Kampanya duzeyinde butce (CBO) bu uclarda kabul
* edilmez ve sessizce dusurulmez -- 400 doner.
*/
export function standaloneAdChainBody(input: {
accountId: string;
adAccountId: string;
dailyBudgetMinorUnits: number;
}): Schemas['CreateStandaloneAdDto'] {
return {
// Meta token'i BU hesaptan cozulur.
accountId: input.accountId,
adAccountId: input.adAccountId,
campaign: { name: 'Spring launch', goal: 'traffic' },
adSet: {
name: 'Broad 25-45',
billingEvent: 'IMPRESSIONS',
optimizationGoal: 'LINK_CLICKS',
dailyBudgetMinorUnits: input.dailyBudgetMinorUnits,
targeting: { geo_locations: { countries: ['TR'] } },
},
// Kreatif bir DUZEY degil, reklamin bir ALANI. Tam olarak bir reklam.
ads: [
{
name: 'Ad 1',
creative: {
headline: 'Spring is here',
body: 'New season, new prices.',
callToAction: 'SHOP_NOW',
linkUrl: 'https://example.com/spring',
},
},
],
};
}
/**
* Kampanya, ad set, kreatif ve reklami TEK cagriyla kurar.
*
* Iki dal da donuyor cunku ikisi de ogretici:
*
* - `created` — hiyerarsi kuruldu. Her sey PAUSED yaratilir; `ACTIVE`
* istediyseniz kampanya EN SON adimda cevrilir, yani yarida kalan bir
* zincir hicbir zaman para harcamamis olur.
* - `failed` — TELAFI SILMESI YOKTUR. Ucuncu cagri dustuyse ilk ikisinin
* Meta'da yarattigi nesneler durmaya devam eder; ne kaldigini `chainLedger`
* soyler ve kalan ad set'e sonradan reklam eklemek desteklenen bir akistir.
* `code` ile hangi ucun dustugunu de anlarsiniz: her ucun kendi onek'i var
* (`facebook_ad_standalone_create`, `facebook_ad_boost_create`, ...).
*
* `idempotencyKey` opsiyonel degil, SART: bu bir yazma ve tekrarlanan istek
* ikinci bir kampanya kurmamalidir. Tekrar ayni `201`i ve saklanan cevabi
* dondurur.
*
* Butcenin AD SET'te oldugunu govdede goruyorsunuz — kampanya duzeyinde butce
* (CBO) bu uclarda kabul edilmez ve sessizce dusurulmez, 400 doner.
*/
export type AdChainOutcome =
| { status: 'created'; result: Schemas['AdChainResponseDto'] }
| { status: 'failed'; code: string; ledger: ChainLedger | undefined };
export async function createAdChain(
zhyper: ZhyperClient,
body: Schemas['CreateStandaloneAdDto'],
idempotencyKey: string,
): Promise<AdChainOutcome> {
try {
const result = await zhyper.request<Schemas['AdChainResponseDto']>(
'createStandaloneAd',
{ body, idempotencyKey },
);
return { status: 'created', result };
} catch (error) {
// Yalnizca SUNUCUNUN reddettigi hata defter tasiyabilir. Aga hic
// cikamamis bir istek (ZhyperConnectionError) burada yutulmamali:
// yaratilmis olabilir de olmayabilir de, ve o ayrim cagirana ait.
if (!(error instanceof ZhyperApiError)) throw error;
return { status: 'failed', code: error.code, ledger: chainLedger(error) };
}
}
The two fields most often sent wrong are called out in the example itself:
goal is our name for the objective, not Meta's ODAX constant, and the
budget sits on the ad set rather than the campaign. The
API reference prints this operation's top-level body
fields and names the schema behind each nested one — campaign is an
AdChainCampaignDto, adSet an AdChainAdSetDto. For the fields inside
those, look the schema name up in the OpenAPI document itself
(Quickstart has the two URLs); the
reference expands one level only, because this surface nests several deep.
These are writes, so give them an Idempotency-Key
(Rate limits and idempotency). A replayed
request answers with the same 201 and the stored response, rather than
building a second campaign.
Nothing spends until the last step
Everything in the hierarchy is created paused. Asking for ACTIVE flips the
campaign as the last step, which is the whole point: a chain that fails
part-way has never spent anything.
A failed chain leaves its work behind — on purpose
If the third call in the chain fails, the two objects already created on Meta are not deleted. There is no compensating rollback. Instead the error carries a ledger of what was made:
/**
* Zincir ucu ortada dustugunde ne kaldi?
*
* Telafi silmesi YOKTUR: Meta'da yaratilmis olan varliklar silinmez, hatanin
* `details.created` alaninda DEFTERE yazilir. Kalan bir ad set cop degil
* tutamaktir — kimligini `adSetId` ile tekrar kullanip reklami ona
* ekleyebilirsiniz.
*/
export interface ChainLedger {
campaignId?: string;
adSetId?: string;
adIds: string[];
}
export function chainLedger(error: unknown): ChainLedger | undefined {
const details = (error as { details?: unknown } | null)?.details;
if (details === null || typeof details !== 'object') return undefined;
const created = (details as { created?: unknown }).created;
if (created === null || typeof created !== 'object') return undefined;
const record = created as { campaignId?: unknown; adSetId?: unknown; adIds?: unknown };
return {
...(typeof record.campaignId === 'string' ? { campaignId: record.campaignId } : {}),
...(typeof record.adSetId === 'string' ? { adSetId: record.adSetId } : {}),
adIds: Array.isArray(record.adIds)
? record.adIds.filter((value): value is string => typeof value === 'string')
: [],
};
}
That leftover ad set is a handle, not garbage. Attaching an ad to an existing ad set is a supported flow, so the ledger gives you somewhere to continue from instead of starting over. Nothing reaps these later — if you decide you do not want them, remove them yourself.
The same thing happens if Meta is slower than we budgeted for. Between steps the
chain checks whether the remaining wall-clock budget still covers one more
provider call; if it does not, it stops there rather than starting a call it
cannot finish, and answers 504 with code
facebook_ad_chain_wall_clock_exceeded and the same details.created ledger.
Nothing is cancelled mid-call, so the ledger is always complete. Whatever was
built stays paused and spends nothing, and the ad set in that ledger is again
where you continue from — resending the whole chain would build a second
hierarchy beside the first.
Each of the five endpoints has its own error code prefix, so you can tell which one broke without reading a stack trace:
| Endpoint | Code prefix |
|---|---|
/v1/ads/create | facebook_ad_standalone_create |
/v1/ads/boost | facebook_ad_boost_create |
/v1/ads/call | facebook_ad_call_create |
/v1/ads/messaging | facebook_ad_messaging_create |
/v1/ads/ctwa | facebook_ad_ctwa_create |
Attaching assets to a Google Ads campaign
POST /v1/ads/campaigns/{campaignId}/assets adds sitelinks, callouts and
structured snippets to an existing Google Search campaign. It is not a hierarchy — there is no
campaign, ad set or ad to build — but it is still a multi-step write, and it
runs under the same server-side wall-clock budget as the Meta chain.
Two provider calls go out in order: the assets are created first, then linked to
the campaign. Between them the same question is asked — does the remaining
budget still cover another provider call? If it does not, the second call is
never started and you get 504 with code
google_ads_attach_wall_clock_exceeded. Nothing is cancelled mid-call.
What that leaves behind is worth being precise about, because it differs from
the Meta chain. The assets were created and they are not linked to the
campaign, so they sit unattached on the account. details.created lists them by
resource name and kind, and nothing removes them later.
Send the request again under the same Idempotency-Key and it carries on
from there. The assets are not created a second time — only the linking call
goes out — and you get the ordinary 201 response naming the same resources.
Retry as often as you need: every attempt that stops before the linking call
leaves the same list behind, so the next one starts from it. Do not switch to a
new key to get past this; a new key builds a second, complete set of assets
beside the first and leaves that first set orphaned.
Carrying on is only offered when we know the linking call never went out. If it
was sent and its outcome could not be established, every retry answers
409 idempotency_key_outcome_unknown instead — deliberately, because sending
that step twice could link the same assets twice. Then look at the campaign in
Google Ads before doing anything else: this endpoint is the only asset surface
we expose today, so there is no call that links an asset you already have, lists
them, or deletes them.
Reading what exists
The tree is the one call that answers "what is out there" in one shape — campaigns, their ad sets, and the ads under them, with metrics rolled up:
// Agac SENKRONLANMIS veriden okunur, saglayicidan degil. Yani yeni yaratilmis
// bir kampanya burada ancak bir sonraki kesif turundan sonra gorunur.
export async function adTree(
zhyper: ZhyperClient,
query: { accountId?: string; fromDate?: string; toDate?: string },
): Promise<Schemas['AdTreeResponseDto']> {
return zhyper.request<Schemas['AdTreeResponseDto']>('getAdTree', { query });
}
Remember the sync. If you just created a campaign and the tree does not show it, nothing is wrong; the discovery pass has not run yet. Everything the tree reports comes from that synced copy, including the metrics.
For a flat, time-ordered view of the same account instead of a nested one:
// `accountId` ZORUNLU: zaman cizelgesi tek bir bagli hesabin reklamlarini
// toplar. Tarih araligi verilmezse son 90 gun kullanilir.
export async function adsTimeline(
zhyper: ZhyperClient,
query: { accountId: string; fromDate?: string; toDate?: string },
): Promise<unknown> {
return zhyper.request('getAdsTimeline', { query });
}
Knowing when something changes
Ad status is not yours to control alone — Meta changes it too, on review outcomes, spend limits and schedule boundaries. Polling the tree will find those changes eventually, but the sync interval decides how late.
Subscribe to ad.status_changed instead. It is the only signal that arrives
because the provider acted rather than because you asked.
See Webhooks for delivery and signature verification.
The envelope is the usual one; the ad-specific part is data:
{
"id": "6a1f9c4d2e8b7a35c0d19f42",
"type": "ad.status_changed",
"createdAt": "2026-08-24T09:31:07.412Z",
"data": {
"platformAdId": "23851234567890123",
"accountId": "5f2c8ab41d7e39064b8c1a20",
"platform": "facebook",
"previousStatus": "active",
"status": "rejected"
}
}
platformAdId is Meta's id for the ad, not ours — use it to line the event up
with what the tree returned. accountId is the connected social account the ad
hangs off, so a single endpoint can serve every account you have connected.
Treat both status fields as open strings. Meta owns that vocabulary and adds
to it; match the values you act on and let everything else fall through a
default, rather than switching exhaustively over a set you do not control. The
event fires only when the two differ, so previousStatus is always something
other than status — and never on the first sync of an ad, where there is no
previous status to compare against. Discovery is what tells you an ad exists;
this event only tells you one changed.
platform is facebook on every ad event today, for the reason at the top of
this page.
What is not here
- A campaign hierarchy on anything but Facebook and Instagram. The Google, LinkedIn and TikTok operations listed at the top of this page are reads and writes against those platforms' own surfaces; there is no campaign → ad set → ad tree behind them.
- Campaign-level budgets (CBO). Rejected, not ignored — see above.
- Automatic cleanup after a failed chain. By design; the ledger is the answer instead.
Everything else in the ads surface — audiences, creatives, images, insights, lead forms, tracking tags and the rest — is listed in the API reference.
Platform capabilities
Platforms do not agree on anything. One takes four images, another takes one. One accepts a 9:16 video and rejects 1:1. One has no analytics per post but plenty per location. Hard-
Billing and balance
Most of what zhyper does costs us nothing per call. One thing does: X charges per API request, and that charge is passed through to you at cost.