Zernio
APIFor you
Zernio
APIFor you
Platforms

Meta Ads

Overview

Set up

Connect

Build

CampaignsAd setsCreativesCreative libraryPreviewsBoost a postLead Gen Forms

Target

TargetingCustom Audiences

Ad types

Creative testingConversion campaignsCatalog adsMessaging & call adsClick-to-WhatsApp adsReach & frequency

Measure

InsightsMeta PixelsConversions APIAd URL tracking tags

Operate

Duplicate & lifecycleAccount & ops readsAd LibraryAd comments

Reference

Enums, limits & error codes
Dashboard
OverviewPlatformsAPI ReferenceResources
Meta Ads

Messaging & call ads

Create Meta ads that open a WhatsApp, Messenger or Instagram Direct conversation with POST /v1/ads/messaging, or dial a number with POST /v1/ads/call.


Create an ad that opens a conversation instead of a web page with POST /v1/ads/messaging. Create one that dials your number with POST /v1/ads/call.

Each call builds the whole hierarchy (campaign, ad set, creative, ad), or attaches the creatives to an ad set you already have.

Before you start

You need accountId and adAccountId. The Page has to be paired with the destination before you call.

For WhatsApp, optional top-level whatsappPhoneNumber selects a number already paired with the Page. Omit it to use the Page's default pairing.

Messaging ads

Each call also picks the call to action, destination_type and optimization goal Meta requires for that destination.

destination selects where the conversation opens:

destinationOpens inCall to actionRequires
whatsappWhatsAppWHATSAPP_MESSAGEThe Page paired with a verified WhatsApp Business number
messengerThe Page's Messenger inboxMESSAGE_PAGEA Facebook Page
instagram_directThe linked Instagram account's Direct inboxINSTAGRAM_MESSAGEAn Instagram professional account linked to the Page
TypeScript
import Zernio from '@zernio/node';

const zernio = new Zernio();

const { data: created } = await zernio.messagingads.createMessagingAd({
  body: {
    accountId: '66b2e19d8c3f5a7e9d0b1c2d',
    adAccountId: 'act_1234567890',
    destination: 'messenger',
    name: 'Summer promo, Messenger',
    headline: 'Chat with us',
    body: 'Get a personal quote in 2 minutes.',
    imageUrl: 'https://cdn.example.com/promo.jpg',
    budgetAmount: 20,
    budgetType: 'daily',
    currency: 'USD',
    countries: ['US', 'ES']
  }
});

const adId = created.ad._id;

Response (201), trimmed. adType tells you which shape you got:

  • single carries one ad.
  • multi, for a request with several creatives, carries ads, platformCampaignId and platformAdSetId instead.
JSON
{
  "adType": "single",
  "message": "CTM ad submitted to Meta for review",
  "ad": {
    "_id": "66d4a1b2c3e4f5a6b7c8d9e7",
    "name": "Summer promo, Messenger",
    "status": "pending_review",
    "reviewStatus": "in_review",
    "platformObjective": "OUTCOME_ENGAGEMENT",
    "platformAdId": "120260000000000002",
    "platformCampaignId": "120250000000000006",
    "platformAdSetId": "120250000000000007"
  }
}

The ad set carries the matching destination_type with CONVERSATIONS optimization, and the campaign objective defaults to OUTCOME_ENGAGEMENT. Send objective to override it.

Both creative shapes work, with an image or a video:

  • the single-creative fields above
  • creatives[] for N ads in one ad set

Unlike POST /v1/ads/create, sending both is a 400 rather than ignoring the top-level fields. Either shape also goes on an existing ad set.

Targeting takes the full geo shape, not only countries. For a local business, pass cities, regions, zips, metros or customLocations, with keys from the targeting search.

The ["US"] default applies only when no geo field at all is present. A cities request targets that city and nothing else.

Targeting, budget and schedule

Messaging and call ads take the same campaign, ad set and targeting fields as POST /v1/ads/create on Meta, built by the same code:

  • Targeting: every targeting field: languages, gender, excludedLocations, behaviors, workPositions, incomeTier, userOs, audienceInclude / audienceExclude, savedTargetingId and the nested targeting object.
  • Raw targeting: rawTargeting is a Meta targeting spec sent as the base layer, for anything the flat fields cannot express. An example is a layered flexible_spec (entries AND together, ids inside one entry OR).
    • Flat fields you also send win per key.
    • With rawTargeting, the US and advantage_audience defaults are not added. Include targeting_automation in it or send advantageAudience.
  • Campaign budget: budgetLevel: "campaign" puts the budget and bid strategy on the campaign (Advantage campaign budget). The default, adset, keeps it on the ad set.
  • Schedule: startDate and endDate take an ISO 8601 date or date-time.
    • A value with an offset is used as is. One without ("2027-01-15T10:00:00") is read in the ad account's timezone.
    • A date-only end runs to 23:59:59 local.
    • A lifetime budget requires endDate.
  • Special ad categories: specialAdCategories and specialAdCategoryCountry on the new campaign.
JSON
{
  "accountId": "66b2e19d8c3f5a7e9d0b1c2d",
  "adAccountId": "act_1234567890",
  "destination": "whatsapp",
  "name": "Quick launch, persona A",
  "headline": "Talk to a specialist",
  "body": "Get a quote on WhatsApp in minutes.",
  "description": "Replies within the hour",
  "imageUrl": "https://cdn.example.com/promo.jpg",
  "budgetAmount": 50,
  "budgetType": "daily",
  "budgetLevel": "campaign",
  "startDate": "2027-01-15T09:00:00",
  "endDate": "2027-02-15",
  "gender": "female",
  "languages": ["en", "es"],
  "rawTargeting": {
    "geo_locations": { "countries": ["US"] },
    "flexible_spec": [
      { "interests": [{ "id": "6003107902433" }, { "id": "6003139266461" }] },
      { "behaviors": [{ "id": "6002714895372" }] }
    ],
    "targeting_automation": { "advantage_audience": 0 }
  }
}

description is the link description, separate from headline and body. It is Meta's link_data.description, or video_data.link_description on a video.

Meta shows it mainly on Facebook Feed, under the headline, when there is room. Instagram, Stories, Reels and Messenger placements do not display it.

It also goes per entry in creatives[], and cannot be combined with an existing post.

The Page-to-destination pairing must exist before you call. A missing pairing is Meta subcode 2446886 inside a platform_error envelope.

POST /v1/ads/ctwa is the deprecated predecessor of destination: "whatsapp". It stays available for existing integrations (click-to-WhatsApp ads).

To attribute conversions that happen inside the WhatsApp thread, see Conversions API for business messaging.

Reuse an existing post

Send one of these instead of headline, body and imageUrl / video, per creative or at the top level:

  • existingPostId, Zernio-resolved the same way postId resolves on a boost
  • a raw Facebook objectStoryId (pageId_postId)

No media is uploaded and the organic post is kept as-is:

JSON
{
  "accountId": "66b2e19d8c3f5a7e9d0b1c2d",
  "adAccountId": "act_1234567890",
  "destination": "whatsapp",
  "name": "Reuse existing reel",
  "existingPostId": "811889972008357_123456789"
}

existingPostId and objectStoryId are mutually exclusive with each other. They are also exclusive with headline, body, imageUrl, video and welcomeMessage.

This only works on messaging and CTWA. Call ads still need a fresh creative.

Attach to an existing ad set

adSetId adds the creatives to a messaging ad set you already have, instead of building a campaign around them.

The ad set keeps its learning phase. That makes this the way to refresh a creative without resetting delivery.

Either creative shape works with it. The target ad set's destination_type has to match the ad's destination: a WhatsApp ad needs a WhatsApp ad set, or Meta would take an ad that never delivers.

Terminal
curl -X POST "https://zernio.com/api/v1/ads/messaging" \
  -H "Authorization: Bearer $ZERNIO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "accountId": "66b2e19d8c3f5a7e9d0b1c2d",
    "adAccountId": "act_1234567890",
    "destination": "messenger",
    "adSetId": "120250000000000007",
    "name": "Summer promo, new creative",
    "headline": "Chat with us",
    "body": "Get a personal quote in 2 minutes.",
    "imageUrl": "https://cdn.example.com/promo-v2.jpg"
  }'

Response (201), trimmed:

JSON
{
  "adType": "single",
  "ad": {
    "_id": "66d4a1b2c3e4f5a6b7c8d9e9",
    "status": "pending_review",
    "reviewStatus": "in_review",
    "platformAdId": "120260000000000004",
    "platformAdSetId": "120250000000000007"
  }
}

The ad set already owns the budget, the targeting and the schedule. Alongside adSetId, these fields are rejected with a 400 rather than quietly dropped:

  • the budget fields and budgetLevel
  • startDate and endDate
  • objective, campaignStatus and existingCampaignId
  • the special ad category fields
  • the targeting fields, except ageMin, ageMax, placements and advantageAudience

The error reads: "adSetId attaches to an existing ad set, which already owns budgetAmount. Remove it."

Without adSetId, the budget fields swap back to required.

Add an ad set to an existing campaign

existingCampaignId creates the new messaging ad set under a campaign you already have, with its own targeting, schedule and creatives. For example, one ad set per persona under one campaign.

The campaign's objective must be OUTCOME_ENGAGEMENT, OUTCOME_SALES or OUTCOME_LEADS.

JSON
{
  "accountId": "66b2e19d8c3f5a7e9d0b1c2d",
  "adAccountId": "act_1234567890",
  "destination": "whatsapp",
  "existingCampaignId": "120250000000000006",
  "adSetName": "Persona B, procurement leads",
  "name": "Quick launch, persona B",
  "headline": "Talk to a specialist",
  "body": "Get a quote on WhatsApp in minutes.",
  "imageUrl": "https://cdn.example.com/promo.jpg",
  "countries": ["US"],
  "workPositions": [{ "id": "105763692790962" }]
}

If the campaign has a campaign budget, it funds the new ad set. Omit budgetAmount and budgetType: sending them is a 400, since the campaign would ignore them.

Otherwise they are required and land on the new ad set.

objective, campaignName, campaignStatus, budgetLevel and the special ad category fields belong to the campaign. They are rejected with a 400.

Different image per placement

placementAssets pins a specific image (or video) to each placement group on one messaging ad. It works the way Ads Manager's "different creative per placement" does: a 4:5 image on Feed and a 9:16 image on Stories and Reels, for example.

It replaces imageUrl / video. headline and body stay required as the default copy.

The call to action, welcomeMessage and whatsappPhoneNumber apply to every placement.

Terminal
curl -X POST "https://zernio.com/api/v1/ads/messaging" \
  -H "Authorization: Bearer $ZERNIO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "accountId": "66b2e19d8c3f5a7e9d0b1c2d",
    "adAccountId": "act_1234567890",
    "destination": "instagram_direct",
    "name": "Summer promo, Direct",
    "headline": "Chat with us",
    "body": "Get a personal quote in 2 minutes.",
    "placementAssets": {
      "defaultImageUrl": "https://cdn.example.com/promo-4x5.jpg",
      "rules": [
        {
          "imageUrl": "https://cdn.example.com/promo-9x16.jpg",
          "placements": {
            "publisherPlatforms": ["facebook", "instagram"],
            "facebookPositions": ["story", "facebook_reels"],
            "instagramPositions": ["story", "reels"]
          }
        }
      ]
    },
    "budgetAmount": 20,
    "budgetType": "daily",
    "currency": "USD",
    "countries": ["US"]
  }'

defaultImageUrl serves every placement no rule matches. Each rule can also pin its own headline or body.

A block is all-image or all-video (defaultVideoUrl + rules[].videoUrl), never mixed.

The block is the same one POST /v1/ads/create takes. See placement asset customization for the full shape.

It works with one destination and on the attach shape. These return a 400:

  • destinations (several apps on one ad set): Meta drops the placement rules from a multi-destination creative, and refuses more than one call to action per placement rule (subcode 1885878). Use one ad set per destination.
  • creatives[]: send one placement-customized creative per request, and attach more to the same ad set with adSetId.
  • imageUrl, video, existingPostId, platformPostId or objectStoryId alongside it.
  • POST /v1/ads/call.

Validate without creating

validateOnly: true sends the whole ad to Meta as a dry run (execution_options: validate_only) and creates nothing. It checks either:

  • the campaign, ad set, creative and ad in one check
  • the creative and ad against the existing ad set, with adSetId

Media is checked by URL, so nothing is uploaded. A success returns 200:

JSON
{
  "validateOnly": true,
  "results": [
    { "node": "campaign", "status": "validated" },
    { "node": "adSet", "status": "validated" },
    { "node": "creative", "status": "validated" },
    { "node": "ad", "status": "validated" }
  ],
  "message": "Validation passed for every node Meta can validate without creating; nothing was created."
}

A Meta rejection comes back as the usual platform_error.

A dry run supports one creative with imageUrl, image placementAssets, an existing video.id or an existing post.

Several creatives, a new video.url and video placementAssets need an upload first, so they return 400.

Call ads

POST /v1/ads/call builds an ad whose call to action is CALL_NOW, dialing phoneNumber in E.164. linkUrl is required alongside it.

TypeScript
const { data: callAd } = await zernio.messagingads.createCallAd({
  body: {
    accountId: '66b2e19d8c3f5a7e9d0b1c2d',
    adAccountId: 'act_1234567890',
    name: 'Book a consult, calls',
    phoneNumber: '+15551234567',
    linkUrl: 'https://example.com/contact',
    headline: 'Talk to an expert',
    body: 'One call, no forms.',
    imageUrl: 'https://cdn.example.com/promo.jpg',
    budgetAmount: 30,
    budgetType: 'daily',
    currency: 'USD',
    countries: ['US']
  }
});

Response (201), trimmed. Call ads take the same shape as messaging ads, so adType is single here and multi for a request with several creatives.

JSON
{
  "adType": "single",
  "message": "Call ad submitted to Meta for review",
  "ad": {
    "_id": "66d4a1b2c3e4f5a6b7c8d9e8",
    "name": "Book a consult, calls",
    "status": "pending_review",
    "reviewStatus": "in_review",
    "platformObjective": "OUTCOME_LEADS",
    "platformAdSetId": "120250000000000008"
  }
}

The ad set uses destination_type: PHONE_CALL optimizing QUALITY_CALL. The campaign objective defaults to OUTCOME_LEADS.

linkUrl must be a real website, such as your contact page. Meta rejects a tel: URI as the creative's link with subcode 2061006, because the number rides on the call to action button only.

Welcome message

welcomeMessage replaces the default greeting in the chat that opens. Both fields are required when the object is present:

JSON
{
  "welcomeMessage": {
    "text": "How can we help with your quote?",
    "prefillText": "I would like a quote for..."
  }
}

text is Meta's page_welcome_message greeting, shown when the chat opens in place of Meta's generic one.

prefillText sits in the user's input box ready to send, in place of Meta's generic opener. One ad can steer the first message toward what it promotes.

On the single-creative shape it is a top-level field. On creatives[], set it per entry. Omit it for Meta's defaults.

Start a workflow from the ad

workflowId binds a workflow in the ad account's profile to the ad. The binding is stored on the ad.

When someone clicks the ad, that workflow starts in the conversation the click opens, ahead of keyword-matched workflows. It starts on the first message, a postback or a standalone referral carrying the ad id, provided it is active on the receiving account.

A workflowId that names no workflow in the profile is a 404. POST /v1/ads/call does not take it.

JSON
{
  "destination": "instagram_direct",
  "workflowId": "66d4a1b2c3e4f5a6b7c8d9e1"
}

The usual workflow rules still apply:

  • no start while another run is live in the conversation
  • no start while a human has it
  • the workflow's cooldownHours

Without a binding, a referral workflow can match the click instead. It reads the ad id from {{referral.adId}}.

Retry safely

A blind retry of POST /v1/ads/messaging or POST /v1/ads/call creates a second campaign, ad set and ad.

Send an Idempotency-Key header (a UUID per logical ad, up to 255 characters). Reuse it on every retry of the same request:

Terminal
curl -X POST "https://zernio.com/api/v1/ads/messaging" \
  -H "Authorization: Bearer $ZERNIO_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 3f6c2a1e-8b4d-4f7a-9c1e-2d5b6a7c8e90" \
  -d '{ "accountId": "66b2e19d8c3f5a7e9d0b1c2d", "adAccountId": "act_1234567890", "destination": "messenger", "name": "Summer promo, Messenger", "headline": "Chat with us", "body": "Get a personal quote in 2 minutes.", "imageUrl": "https://cdn.example.com/promo.jpg", "budgetAmount": 20, "budgetType": "daily", "currency": "USD", "countries": ["US", "ES"] }'

The first request with a key creates the ad and stores its response.

A retry with the same key and body replays that exact response with an Idempotent-Replayed: true header. It creates nothing.

  • The same key with a different body returns 422.
  • A key whose first request is still in flight returns 409. Back off briefly and retry.

Keys are scoped to your credential and expire after 24 hours. Idempotency covers the header across every write endpoint.

If it fails

A 422 means no Facebook Page resolved for the account:

JSON
{
  "error": "No Facebook page found. Connect a Facebook account with a page to create CTM ads.",
  "type": "invalid_request_error",
  "code": "linked_account_required",
  "platform": "meta"
}

Connect a Facebook account that manages a Page in the same profile, then retry.

A missing destination pairing fails later, at Meta. It arrives as a 400 with type: "platform_error" and subcode 2446886:

JSON
{
  "error": "Meta Ads API error (400; code 100, subcode 2446886): Your Page is not linked to a WhatsApp account.",
  "type": "platform_error",
  "platform": "meta",
  "platformError": { "code": 100, "error_subcode": 2446886 }
}

Pair the Page in Meta Business Manager, then retry.

A 403 with code: "ads_allowance_exceeded" means the team has no payment method on file and has reached its 500 free live ads. Add a card to resume.

A 502 means Meta accepted the request and then failed to produce the media. Read platformError.reason.

Related

  • Click-to-WhatsApp ads: the deprecated endpoint this one replaces.
  • Targeting: the targeting fields these endpoints accept.
  • WhatsApp: the inbox the conversations land in.
  • Idempotency: the Idempotency-Key header on every write endpoint.
  • Create messaging ad and Create call ad: every field.
Was this page helpful?

Catalog ads

Run Advantage+ catalog ads from a Meta product catalog with goal catalog_sales, and find the catalog and product set to promote.

Click-to-WhatsApp ads

Keep an existing POST /v1/ads/ctwa integration running, and see what the newer messaging endpoint replaces it with.

On this page

Before you startMessaging adsTargeting, budget and scheduleReuse an existing postAttach to an existing ad setAdd an ad set to an existing campaignDifferent image per placementValidate without creatingCall adsWelcome messageStart a workflow from the adRetry safelyIf it failsRelated