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:
destination | Opens in | Call to action | Requires |
|---|---|---|---|
whatsapp | WHATSAPP_MESSAGE | The Page paired with a verified WhatsApp Business number | |
messenger | The Page's Messenger inbox | MESSAGE_PAGE | A Facebook Page |
instagram_direct | The linked Instagram account's Direct inbox | INSTAGRAM_MESSAGE | An Instagram professional account linked to the Page |
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:
singlecarries onead.multi, for a request with several creatives, carriesads,platformCampaignIdandplatformAdSetIdinstead.
{
"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,savedTargetingIdand the nestedtargetingobject. - Raw targeting:
rawTargetingis a Meta targeting spec sent as the base layer, for anything the flat fields cannot express. An example is a layeredflexible_spec(entries AND together, ids inside one entry OR).- Flat fields you also send win per key.
- With
rawTargeting, the US andadvantage_audiencedefaults are not added. Includetargeting_automationin it or sendadvantageAudience.
- 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:
startDateandendDatetake 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
lifetimebudget requiresendDate.
- A value with an offset is used as is. One without (
- Special ad categories:
specialAdCategoriesandspecialAdCategoryCountryon the new campaign.
{
"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 waypostIdresolves on a boost- a raw Facebook
objectStoryId(pageId_postId)
No media is uploaded and the organic post is kept as-is:
{
"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.
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:
{
"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 startDateandendDateobjective,campaignStatusandexistingCampaignId- the special ad category fields
- the targeting fields, except
ageMin,ageMax,placementsandadvantageAudience
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.
{
"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.
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 (subcode1885878). Use one ad set per destination.creatives[]: send one placement-customized creative per request, and attach more to the same ad set withadSetId.imageUrl,video,existingPostId,platformPostIdorobjectStoryIdalongside 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:
{
"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.
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.
{
"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:
{
"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.
{
"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:
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:
{
"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:
{
"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-Keyheader on every write endpoint. - Create messaging ad and Create call ad: every field.