zhyper docs

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.
  • LinkedInPOST /v1/ads/targeting/bid-pricing and POST /v1/ads/targeting/supply-forecast.
  • TikTokGET /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:

EndpointWhat it makes
POST /v1/ads/createA standalone ad from a new creative
POST /v1/ads/boostAn ad from a post that already exists
POST /v1/ads/callA call ad
POST /v1/ads/messagingA messaging ad
POST /v1/ads/ctwaA 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:

EndpointCode prefix
/v1/ads/createfacebook_ad_standalone_create
/v1/ads/boostfacebook_ad_boost_create
/v1/ads/callfacebook_ad_call_create
/v1/ads/messagingfacebook_ad_messaging_create
/v1/ads/ctwafacebook_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.

On this page