Click-to-WhatsApp Ads
Capture CTWA clicks and forward conversion events to Meta for attribution
Click-to-WhatsApp ads & attribution
Click-to-WhatsApp (CTWA) ads are Meta ads that, when tapped, open a WhatsApp conversation with your business instead of a website. They're a Meta Ads concept, Zernio creates them via POST /v1/ads/ctwa (see the Meta Ads page for the request shape and prerequisites).
This page covers the WhatsApp side of the loop: capturing the originating click and sending conversion events back to Meta for attribution.
Automatic ctwa_clid capture
When a user reaches your WhatsApp business via a CTWA ad, Meta attaches a referral object, including ctwa_clid (Meta's click ID for WhatsApp), to the first inbound message of the conversation. Zernio's webhook handler captures it automatically and persists it on the Conversation record's metadata:
{
"metadata": {
"ctwa_clid": "AbCdEfGhIjKlMn0pQrStUvWxYz",
"ctwa_captured_at": "2026-04-27T09:18:44.991Z",
"ctwa_source_id": "120000000000000000",
"ctwa_source_url": "https://fb.me/...",
"ctwa_headline": "Chat with us on WhatsApp",
"ctwa_source_type": "ad"
}
}The capture is one-shot, once ctwa_clid is set, later messages from the same user never overwrite it (Meta only emits referral on the first message after a click).
Conversions API for Business Messaging
Forward conversion events that happen inside the WhatsApp thread back to Meta with action_source = business_messaging so they attribute to the originating CTWA ad. Distinct from Meta Ads' general Conversions API: that one is for web pixel events; this one is purpose-built for messaging conversions.
Provision the dataset
Before sending events you need a Meta dataset linked to the WABA. Zernio creates one for you in a single call. The endpoint is GET-first idempotent (a WABA can only own one CTWA dataset), so re-running it is a safe no-op that returns the existing ID with created: false.
const { data } = await zernio.whatsapp.createWhatsAppDataset({
body: { accountId: 'WHATSAPP_ACCOUNT_ID' }
});
console.log(data.datasetId, data.created); // created: false if one already existedresponse = client.whatsapp.create_whats_app_dataset(
account_id='WHATSAPP_ACCOUNT_ID'
)
print(response.dataset_id, response.created) # created: False if one already existedcurl -X POST "https://zernio.com/api/v1/whatsapp/dataset" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"accountId": "WHATSAPP_ACCOUNT_ID"}'The dataset ID is persisted on the WhatsApp account as metadata.metaCapiDatasetId, which is what POST /v1/whatsapp/conversions reads to attribute events. There is also a GET /v1/whatsapp/dataset?accountId= that returns the persisted ID without touching Meta, useful for surfacing whether tracking is set up on an account.
If Meta rejects the call with (#100) Invalid parameter, the most common cause is that the connected WhatsApp Business Account is not fully verified yet. Complete business verification in Meta Business Manager and try again. The endpoint returns 422 and includes Meta's raw error so other causes (region restrictions, app-level disablement) are visible. If you connect via Headless Credentials, make sure your System User token includes whatsapp_business_manage_events, the scope is auto-extended to tokens minted through the Redirect Flow but not to System User tokens you mint yourself.
Prefer to use an existing dataset (for example, one you already own in a different Business Manager)? Skip the provisioning call and set metadata.metaCapiDatasetId directly. In that case the WhatsApp account's access token must have access to that dataset, a WABA's system-user token is scoped to the WABA's own Business Manager and cannot post to a pixel owned by a different Business (Meta returns code 100). Share the dataset with the WhatsApp app's Business in Meta Business Manager, or use one already in the same Business.
Send a conversion event
const result = await zernio.whatsapp.sendWhatsAppConversion({ body: {
accountId: 'WHATSAPP_ACCOUNT_ID',
conversationId: 'CONVERSATION_ID', // or use phoneE164
eventName: 'LeadSubmitted',
eventId: 'lead_abc_123',
value: 49.00,
currency: 'USD',
}});result = client.whatsapp.send_whats_app_conversion(
account_id="WHATSAPP_ACCOUNT_ID",
conversation_id="CONVERSATION_ID",
event_name="LeadSubmitted",
event_id="lead_abc_123",
value=49.00,
currency="USD",
)curl -X POST "https://zernio.com/api/v1/whatsapp/conversions" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"accountId": "WHATSAPP_ACCOUNT_ID",
"conversationId": "CONVERSATION_ID",
"eventName": "LeadSubmitted",
"eventId": "lead_abc_123",
"value": 49.00,
"currency": "USD"
}'Check recent activity
List the most recent conversion events sent for a WhatsApp account, useful if you want to mirror the "Conversions" tab inside your own dashboard or build alerting on top of attribution sends.
const { data } = await zernio.whatsapp.listWhatsAppConversions({
query: { accountId: 'WHATSAPP_ACCOUNT_ID', limit: 50 }
});
data.events.forEach(e => {
console.log(e.timestamp, e.eventName, e.conversationId, e.eventsReceived, e.eventsFailed);
});response = client.whatsapp.list_whats_app_conversions(
account_id='WHATSAPP_ACCOUNT_ID', limit=50
)
for e in response.events:
print(e.timestamp, e.event_name, e.conversation_id, e.events_received, e.events_failed)curl "https://zernio.com/api/v1/whatsapp/conversions?accountId=WHATSAPP_ACCOUNT_ID&limit=50" \
-H "Authorization: Bearer YOUR_API_KEY"Each row carries the event timestamp, the event name (one of the supported event names), the originating Zernio conversationId, eventsReceived / eventsFailed as Meta reported them on the send, the Meta traceId (fbtrace_id, handy for cross-referencing in Meta Events Manager), and how long the send took.
The feed is sourced from delivery logs, not a persisted event store, so the window is bounded by log retention (about 30 days). If you need a long-lived audit trail, persist on your side as you call POST /v1/whatsapp/conversions.
Supported event names
Meta's allowlist for business_messaging events is narrower than the standard pixel CAPI:
| Event | Use for |
|---|---|
LeadSubmitted | Form filled, contact details captured, lead qualified |
Purchase | Customer completed a purchase (payment confirmed, invoice issued) |
AddToCart | Customer added an item to cart in-thread |
InitiateCheckout | Customer started a checkout flow |
ViewContent | Customer viewed a specific product / service |
Lead (the standard pixel name) is not accepted on business_messaging events. Use LeadSubmitted instead. Other standard pixel names (CompleteRegistration, Subscribe, Schedule, Contact, StartTrial, AddPaymentInfo, Search, SubmitApplication) are also rejected, these constraints are live-verified against Graph API v25.0 and enforced at Zernio's request boundary.
Attribution window & test mode
Meta's attribution window is 7 days from click. If the customer's ctwa_clid was captured more than 7 days ago, the event still posts but won't attribute.
Pass testCode: "TEST12345" at the request root to route events to the Test Events tab in Meta Events Manager without affecting production dataset data.
Resolving the conversation
You can identify the originating conversation either way:
conversationId, preferred. The Zernio Conversation_idreturned by list conversations.phoneE164, fallback. Digits only, no+. The handler picks the most recent CTWA-attributed conversation for that phone on the supplied account.
If the resolved conversation has no captured ctwa_clid, the request returns 422, there's nothing to attribute (the conversation didn't originate from a CTWA ad, or pre-dates Zernio's capture).