I recently built a proactive-notification channel on top of the WhatsApp Cloud API for a small business — the kind of thing that sends "your X is ready" or "there's an outage at location Y" straight to a customer's WhatsApp, triggered by a backend system rather than a human typing.
On paper it's a few API calls. In practice I lost hours to failures that returned HTTP 200 and looked successful, to a UI button that was simply broken, and to rules Meta enforces but doesn't surface until you trip over them.
This is the write-up I wish I'd found. Every ID, token, and number below is a placeholder — swap in your own.
1. Cloud API vs. the WhatsApp Business app — pick the right tool first
If your goal is proactive messaging — notifying a customer who has not messaged you first — the WhatsApp Business app on a phone will not do it. It can only reply inside an open conversation window.
Proactive, system-triggered messages require:
- the Cloud API (or on-premise API), and
- a pre-approved message template.
A useful mental model that saved me a lot of confusion:
The Cloud API is a pipe, not a faucet. There is no screen where someone logs in and types a message. It only accepts calls from a program. To actually operate it you either build an integration or plug in a third-party platform. Out of the box, all you can do is send via
curl.
Decide this on day one. It changes your whole architecture.
2. The silent killer: accepted does not mean delivered
This one cost me the most time, so it goes first.
My send call returned a clean success:
{ "messaging_product": "whatsapp", "messages": [ { "id": "wamid.XXXX" } ] }
HTTP 200. A valid wamid. And the message never arrived — not on my phone, not on a second test phone. No error anywhere.
Root cause: the Meta app was still in Development / Unpublished mode. In that mode Meta accepts the API call and returns success but does not deliver the message. The failure is 100% silent.
Fix: publish the app.
developers.facebook.com → your app → Publish
The only prerequisite is a Privacy Policy URL under App settings → Basic. It has to resolve to a real page with real content or validation fails. Once published, the exact same request delivered on the first try.
Lesson: accepted ≠ delivered. Without webhooks configured you have no way to tell the difference. If the API accepts your message but nothing arrives, check the app mode before any other hypothesis.
3. When the Meta UI lies, go straight to the Graph API
To activate a number you have to register it. The dashboard has a "Register" button for this. In my case that button returned:
Registration failed. Please try again.
…every time, with no real cause. I burned hours ruling things out: the SIM had never had WhatsApp installed, the OTP was entered correctly, two-step verification was off, it wasn't a rate limit (same failure after 22h), and business verification isn't required to register.
The button was simply bugged. The identical operation via the Graph API worked on the first attempt:
curl -i -X POST \
"https://graph.facebook.com/v21.0/<PHONE_NUMBER_ID>/register" \
-H "Authorization: Bearer $WA_TOKEN" \
-H "Content-Type: application/json" \
-d '{"messaging_product":"whatsapp","pin":"<YOUR_6_DIGIT_PIN>"}'
Response: HTTP 200, {"success": true}.
Lesson: when Meta's UI throws a generic error, hit the Graph API directly. It either returns the real error (far more actionable than "try again") or, as here, just works because the UI was the only broken part.
The same applies to reading state. The developer dashboard often shows stale status. The API is the source of truth:
curl -i "https://graph.facebook.com/v25.0/<PHONE_NUMBER_ID>?fields=status" \
-H "Authorization: Bearer $WA_TOKEN"
# -> {"status":"CONNECTED","id":"<PHONE_NUMBER_ID>"}
4. Template gotchas
Templates are where a surprising number of rules hide.
- Category = money. Choose Utility, not Marketing, whenever the message is transactional (status, alert, confirmation). In some markets Marketing costs on the order of ~12× more per message. Miscategorizing is a silent budget leak.
-
hello_worldonly sends from the test number. You cannot validate your production pipe with the sample template. From a real number you get:
(#131058) Hello World templates can only be sent from the Public Test Numbers
Plan for it: you can't do an end-to-end send from your real number until your own template is approved. (A failed hello_world attempt still proves token validity, sender acceptance, active billing, and API reachability — the infra is fine, you're just waiting on the template.)
- Keep v1 dead simple. No header, no footer, no buttons approves faster.
-
Buttons don't render on WhatsApp Web. A template with buttons can show
This message couldn't loadon desktop. Always verify on a phone, not Web. - Editing or resending a pending template restarts the review queue. Submit once, then wait. Approval is usually well under 48h; past that, contact support instead of poking it.
- Watch the message validity period (TTL). The form defaults to a short validity (as little as 10 minutes) when you don't set a custom one. For a time-sensitive alert that's fine; for something useful for hours, a phone that was briefly off will silently expire the message. It's editable later — but review it before you operate at volume.
Use UTF-8 for non-English languages (accents included) — WhatsApp is UTF-8 end to end.
5. Phone-number gotchas
-
E.164 only. Country code + area code + number, digits only. No leading
0, no carrier-selection prefix.55+ area + number for Brazil, and nothing else. -
The Brazilian 9th digit gets normalized away. WhatsApp strips the mobile
9in thewa_id(e.g.55 61 9XXXX-XXXX→55 61 XXXX-XXXX). This is normal normalization, not a bug. Don't "fix" it. - You don't need the recipient in your contacts. WhatsApp delivers from unknown business numbers normally.
- A number registered on the Cloud API is consumed by the API. It can only send/receive through the API — it can no longer be used in the regular WhatsApp or WhatsApp Business app. Installing the app on that SIM breaks the registration. Pick a dedicated number and leave the app off it.
6. Account setup that doesn't get you banned
The single most expensive mistake I saw was structural, not technical.
A brand-new Facebook account, created from scratch and immediately used to spin up business assets, is a red flag to Meta. That pattern — fresh identity, instant business, sometimes over a VPN — looks like a disposable/fraud account, and it can get permanently restricted with no appeal, taking every asset attached to it down with it (portfolio, app, number registration).
What works instead:
- Use an established, verified Business account as the administrator. An admin account holding multiple client portfolios is the normal agency/studio pattern — legitimate, expected use.
- Create a separate business portfolio per client. This isolates risk (a quality complaint on one channel won't drag down another) and billing (each client's spend stays in its own place).
- Don't rush. Don't do it over a VPN. Don't reuse an email already tied to a flagged asset.
- Business verification is optional to register a number and to deliver — but it raises your throughput tier (e.g. from 250 to 1,000+ business-initiated conversations per 24h), so do it before you scale.
The reputation of the admin account is what carries you. Protect it.
7. Tokens: temporary vs. permanent, and token ≠ app secret
Two traps here.
Temporary vs. System User token. The token you generate in the dashboard's "Try it out" is short-lived (~24h) — fine for testing, useless for production. For a real integration create a System User with a non-expiring token, scoped to only the assets it needs:
Business Settings → Users → System users → Add
→ assign the app + the WABA (Full access)
→ Generate token with:
whatsapp_business_messaging
whatsapp_business_management
whatsapp_business_manage_events
When generating, opt in to only the WhatsApp account you're working on. If your admin account manages several clients' WABAs, do not tick the others.
Token ≠ App Secret. These are different credentials for different jobs, and a webhook consumer usually needs both:
- the token (
EAA…) authenticates outbound message sends; - the App Secret validates the
X-Hub-Signature-256on inbound webhooks. A fail-closed webhook receiver won't even boot without it.
Hygiene: keep tokens in an environment variable so they never land in a screenshot or shell history:
export WA_TOKEN='...'
curl ... -H "Authorization: Bearer $WA_TOKEN"
A non-expiring token is a permanent liability if leaked — a leak is valid forever until someone revokes it (Business Settings → Users → System users → Revoke tokens). Don't transmit it over chat; if you must, delete the message from both ends once it's deployed.
8. Without webhooks, your channel is blind and deaf
Sending is only half of it. Until you configure webhooks:
- you can't tell if the message was delivered (you only get
accepted), and - if the recipient replies, that reply is lost — nobody sees it.
Configure it under your app → Webhooks:
-
Callback URL: your endpoint, already live and answering the
GETverification handshake with the agreed verify token before you register it — Meta rejects the callback otherwise. -
Subscribe to
messages— that field covers both inbound messages and delivery-status events.
Treat webhooks as part of v1, not a nice-to-have.
9. Billing: there is no spend cap
Coming from Meta ads (which have budget limits), I assumed the Cloud API did too. It does not. Billing is pure usage-based; the only ceiling Meta enforces is the volume tier, not spend.
Your only real financial guardrail is the limit on the card attached to the WABA. Set one. As a reference point, at roughly US$0.005/message a modest monthly card limit covers thousands of notifications — the limit mostly protects you against a bug that loops and sends in a runaway.
If your billing currency ends up as USD (in some countries the local currency isn't offered in this flow), remember the charge lands as an international purchase — factor in FX spread and any local tax when you reconcile costs. Reconcile against the card statement in your local currency, not the dollar figure Meta shows.
The condensed checklist
[ ] Cloud API (not the app) if you need proactive messaging
[ ] App PUBLISHED (Development mode accepts but never delivers)
[ ] Number registered — via Graph API if the UI button fails
[ ] Number is DEDICATED — never install the WhatsApp app on that SIM
[ ] Template = Utility (not Marketing), no buttons in v1, TTL reviewed
[ ] Established admin account + one portfolio per client
[ ] System User token (non-expiring), scoped to one WABA
[ ] App Secret wired for webhook signature validation
[ ] Webhooks subscribed to `messages` (delivery + replies)
[ ] Card spend limit set (Meta has no budget cap)
[ ] Business verification done before scaling past the base tier
Why I wrote this
Most of these aren't in the official docs — they're the kind of thing you only learn by shipping. If it saved you an afternoon, it did its job.
I build mobile apps (React Native) and WhatsApp/Meta API integrations for small and mid-size businesses, with a background in health-sector software. If you're wiring up a WhatsApp channel and something's returning success but not working, that section 2 is almost always the answer.
Top comments (1)
WhatsApp production work always seems to have two layers: the API calls and the operational reality around templates, review delays, webhook retries, and user consent. The docs can explain the endpoints, but the real reliability comes from treating messaging as a state machine rather than a send button.