Meta's Marketing Messages API for WhatsApp — What I Got Wrong

Spent a chunk of a day chasing a staging bug that turned out to be me trusting old journal notes instead of the actual docs. Writing this down so I don't repeat it.

(Not to be confused with WhatsApp's simple click-to-chat links — I wrote about those separately — this is the Business Platform marketing-send API, a completely different feature.)

What MM API actually is

Meta's Marketing Messages (MM Lite) API is a separate send path for WhatsApp marketing template messages. Same billing and template setup as the regular Cloud API, but Meta runs it through a quality-based delivery pipeline that's supposed to improve delivery rates for high-engagement messages, plus gives you GIF headers, Android deep links, and a configurable TTL (12 hours to 30 days) instead of the Cloud API's fixed expiry. Marketing templates only — no auth/utility/service messages.

Onboarding — where I went wrong

My notes from an earlier pass said onboarding was a server-to-server call:

POST /marketing_messages_enrollment

Ran it in staging, got back:

Unsupported post request. Object with ID 'marketing_messages_enrollment' does not exist, cannot be loaded due to missing permissions, or does not support this operation.

Worked locally, failed in staging, so I assumed it was an access-token mismatch between environments. Spent an hour comparing .env files before actually re-reading Meta's current docs.

There is no marketing_messages_enrollment endpoint. Never was one I could find in the current docs. Onboarding is just accepting a Terms of Service — App Dashboard → WhatsApp → Quickstart → the "marketing messages" module → accept ToS. Or via Embedded Signup / Intent API / Intent UI if you want it programmatic-ish. My old notes had invented an endpoint that doesn't exist, and Meta's error message (treating the string as a node ID) made it look like a permissions problem instead of a "this isn't a real request" problem.

Checking onboarding status

The field name in my old notes was also wrong (marketing_messages_eligibility — doesn't exist). The real fields:

curl "https://graph.facebook.com/v24.0/<WABA_ID>?fields=name,marketing_messages_lite_api_status,marketing_messages_onboarding_status" \
  -H "Authorization: Bearer <ACCESS_TOKEN>"
{
  "name": "...",
  "marketing_messages_lite_api_status": "ELIGIBLE",
  "marketing_messages_onboarding_status": "ELIGIBLE",
  "id": "<WABA_ID>"
}

Values progress INELIGIBLE → ELIGIBLE → ONBOARDED. Only ONBOARDED can actually send. ELIGIBLE just means the WABA is allowed to onboard, not that it's live.

Sending a message

Same body schema as the regular Cloud API /messages call — the only difference is the endpoint, and it lives on the phone number, not top-level:

curl "https://graph.facebook.com/v24.0/<PHONE_NUMBER_ID>/marketing_messages" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <ACCESS_TOKEN>" \
  -d '{
    "messaging_product": "whatsapp",
    "recipient_type": "individual",
    "to": "<RECIPIENT_PHONE>",
    "type": "template",
    "template": {
      "name": "<TEMPLATE_NAME>",
      "language": { "code": "id" },
      "components": [
        { "type": "body", "parameters": [{ "type": "text", "text": "some-value" }] }
      ]
    }
  }'

Marketing templates only. Non-marketing templates will get rejected here.

Confirming it actually went out via MM Lite

This one's easy to miss: sending to /marketing_messages doesn't guarantee Meta actually routed it through the MM Lite pipeline instead of silently falling back to regular Cloud API delivery. The webhook status payload tells you which one happened:

{
  "conversation": { "id": "...", "origin": { "type": "marketing_lite" } },
  "pricing": { "billable": true, "pricing_model": "PMP", "category": "marketing_lite" }
}

If origin.type and pricing.category aren't marketing_lite, it went out as a normal Cloud API send. Worth asserting on this in any integration test, not just checking for a 200 on the send call.

Debugging "works locally, fails in staging"

The diagnostic sequence that actually found the real problem, in order:

# 1. Make sure the env value is actually reaching the app (not stale config cache)
php artisan config:clear && php artisan cache:clear
php -r "require 'vendor/autoload.php'; \$app = require 'bootstrap/app.php'; echo config('services.meta.app_id'), PHP_EOL;"

# 2. Ask Meta directly which app the token actually belongs to
APP_TOKEN="<APP_ID>|<APP_SECRET>"
curl -G "https://graph.facebook.com/v21.0/debug_token" \
  --data-urlencode "input_token=$APP_TOKEN" \
  --data-urlencode "access_token=$APP_TOKEN"

debug_token tells you the real App ID and scopes behind whatever token you're using — don't trust what a config panel displays, dump it directly. If the App ID differs from what you expect, that's an env/config-cache problem. If it's the same App ID but still fails, it's a Meta-side whitelisting problem (MM Lite is a limited program, not on by default — check App Dashboard → Products that both environments' apps actually have it enabled).

Also worth checking: API version drift. My code defaulted to v24.0 but my own notes were written against v21.0. A couple of fields/endpoints moved between versions, and a version mismatch can produce the exact same "object does not exist" error as a real permissions problem — one more reason not to trust the error message at face value.

Geographic limits (relevant if you have a global audience)

  • Indonesia and most countries: full feature set, delivery optimization included.
  • EEA, UK, Japan, South Korea, Nigeria, South Africa: sends still work, but no delivery optimization and no click/conversion reporting.
  • United States: blocked since April 1 2025 (error 131049) — this is a Meta policy across all business messaging, not MM-specific. A US business number can still message non-US recipients.
  • Cuba, Iran, North Korea, Syria, Venezuela, and sanctioned Ukrainian regions: not eligible to onboard at all.
  • Russia, Belarus: allowed since June 20 2025, but without delivery optimization or conversion metrics.

If you're routing marketing sends by recipient country, US numbers are the one that'll actually hard-fail, not just degrade.

Source

Subscribe to Building software. Writing what I learn.

Don’t miss out on the latest issues. Sign up now to get access to the library of members-only issues.
[email protected]
Subscribe