agentleFS
guisa17guisa17/whatsapp-cloud-api-skill/SKILL.md

whatsapp-cloud-api

Skill1 starsChanged 28 days agoMITView on GitHub

Build, configure and debug bots on Meta's WhatsApp Cloud API. Use for any Cloud API task — access tokens, webhooks that verify but never deliver, WhatsApp Flows, message templates, rate limits, pricing, test numbers, Coexistence, App Review — and especially when the panel says "connected successfully" but no messages arrive, an approved permission behaves as if it were missing, or an API call fails with an opaque error code.

  • Reads credentials
  • Sends data out

2 other projects use a version of this.

Adopted by

Most recently taken up by

  • Other versions2 projects use a version of this.

    Word for word · 0

    None found.

    Edited · 0

    None found.

    Wrote their own version · 2

    1. vobase/vobase★ 60same name
    2. wilfreud/skills-or-something★ 2same name

    How matches are found. Word for word: the exact same text. Edited: shares passages with this file (“99% same” means 99% of its five-word phrases also appear here; 10% or more counts). Their own version: similar meaning, or, weakest, the same name. We can’t tell which project had it first. Projects are counted among the 200 most-starred candidates, and mentions are links to the repository we found, up to 50 per site, so both counts are a floor.

The file

We split this skill into the rules it gives an agent

11 rules. About 0 are common, 8 are distinctive, and 3 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈8

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 3

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    [field]: When it expires, your bot still *receives* messages but fails to send.

    line 100
  2. Distinctive:

    Keep the JSON versioned in your repo and publish it:

    line 237
  3. Distinctive:

    After saving, the video check stays grey while Meta processes the video, which can take a while.

    line 571
  4. Distinctive:

    All five green: Submit for review (bottom right) opens a confirmation modal with a terms checkbox → Submit.

    line 611
  5. Very specific:

    Every post-connection step — subscribed_apps, assigning the system user, the WABA_ID in your config — has to point at the WABA the flow …

    line 691
  6. Distinctive:

    So:: don't pre-create a WABA for a Coexistence number and assume it will be used.

    line 695
  7. Very specific:

    Don't request permissions you don't use — it complicates review, and the reviewer will ask what you need them for.

    line 775
  8. Distinctive:

    App + test number: Don't touch a real number until the flow works — changing the webhook on a number in use interrupts service.

    line 837
  9. Distinctive:

    App Review / Coexistence: only when moving to a real number.

    line 850
  10. Distinctive:

    Deleting the branch doesn't delete the object: If a secret reached a push, the commit is still downloadable — rotate the credential.

    line 868
  11. Very specific:

    When Meta changes something, updating the last-verified date matters as much as the content: a stale skill is worse than no skill, because …

    line 883

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

WhatsApp Cloud API

Field notes from shipping production bots on Meta's Cloud API.

The one thing to internalize: in this API, the default failure mode is silence. The three most common misconfigurations produce no error, no warning, and no failed request — the panel says "connected successfully" and messages simply never arrive. If something isn't working and there's nothing in your logs, start at §3.

How to read this

Claims are tagged so you know what to trust:

  • [verified] — confirmed against Meta's docs or a live API call on the date below.
  • [field] — observed in production. True at least once, for at least one setup.
  • [unconfirmed] — reported by third parties, not found in Meta's docs. Verify before acting.

Last verified: 2026-10-09 against Graph API v26.0. Anything about where a button lives in the panel ages fastest — Meta redesigns often. Treat §1 as a hint, not a map.

Each row says when it was last re-checked:

ClaimRe-verifiedNote
Graph API is on v26.02026-10-08Unchanged; released 2026-07-29. No v27.0 changelog yet
Pricing is per message since 2025-07-012026-10-08Unchanged
Service messages and in-window utility templates are billable since 2026-10-012026-10-08In effect. See §7
1,000 free service messages per number per month2026-10-08[unconfirmed] — consistent across BSPs, not on Meta's non-template page. See §7
Flow JSON freeze/expiry + ~90-day notice2026-09-03Unchanged
Latest Flow JSON is 7.32026-09-03Confirmed Recommended in Meta's changelog. The page 500s to scripts but renders in a browser — see §5
Uploading an asset to a published Flow sends it back to DRAFT2026-09-26Corrects the old "published = immutable" claim. See §5

1. Panel navigation

[field, 2026-08] developers.facebook.com is organized by use cases. There is no "WhatsApp" item in the app sidebar; looking for one wastes time.

What you needWhere it lives
Temp token, Phone Number ID, WABA ID, test numberApp Dashboard → "Customize the 'Connect with customers on WhatsApp' use case" → Step 1: Try it
Webhook URL and verify tokenStep 2: Production setup → Configure webhooks
Permissions and access levelOther tools → Permissions and features
Permanent tokenDifferent site: business.facebook.com → Business settings → Users → System users

Identify screens by content, not menu labels. "Step 1: Try it" is the one showing the test number, Phone Number ID, WABA ID, and a "To" selector for sending a test message. That description will survive redesigns; the label won't.

⚠️ [field] Portfolios often contain several apps with the same name. The name is not enough to know which one you're holding. Compare IDs, and make your tooling print the ID of the app that owns the token.

The documentation moved

[verified, 2026-09] Meta reorganized the developer docs. WhatsApp pages now live under:

https://developers.facebook.com/documentation/business-messaging/whatsapp/...

The old /docs/whatsapp/... paths 404 rather than redirect — for example …/docs/whatsapp/embedded-signup/onboard-whatsapp-business-app-users is gone, and the page is now at …/documentation/business-messaging/whatsapp/embedded-signup/onboarding-business-app-users/. Some old paths still serve a stale copy instead of 404ing, which is worse: you read outdated content believing it's current. If a link in a blog post (or in an older version of this file) points at /docs/whatsapp/, re-find the page under the new prefix before trusting it.


2. Tokens

Temporary (24 hours)

Found in Step 1: Try it. Fine for getting started, expires the next day:

Error validating access token: Session has expired on ...  (code 190)

[field] When it expires, your bot still receives messages but fails to send. If your code queues failed sends for human review, you'll see conversations piling up while customers get nothing.

Permanent (system user)

business.facebook.com → Business settings → Users → System users → Add → Generate token.

  • System user role: Employee is enough. Admin is not required.
  • Assets: the app and the WhatsApp account, both with full control.
  • Permissions: whatsapp_business_messaging and whatsapp_business_management.
  • Expiration: Never.

⚠️ [field] Requires approval from another business admin. Meta enforces two-person control for non-expiring tokens; the request expires after 7 days. The temporary token keeps you unblocked meanwhile.

⚠️ [field] There are two "WhatsApp Accounts" entries in the asset picker: the first lists WABAs (by name), the second lists phone numbers. You must assign in both.

Identify which token you hold

curl -s "https://graph.facebook.com/v26.0/debug_token?input_token=$TOKEN&access_token=$TOKEN" \
  | python3 -c "import sys,json; d=json.load(sys.stdin)['data']; \
print(d['type'], '| expires:', d.get('expires_at') or 'NEVER', '|', d.get('scopes'))"

SYSTEM_USER + expires: NEVER is the one you want.

Token types are not interchangeable

[verified] Some endpoints require an app access token ({app_id}|{app_secret}), not a user or system-user token. Calling GET /{app-id}/subscriptions with the wrong one returns:

(#190) Application Secret required for this endpoint

This matters for diagnostics: a tool that treats "couldn't check" as "not configured" reports a problem that doesn't exist and sends you hunting for nothing. Distinguish the two states explicitly.


3. The three invisible steps

None produce an error. All three produce the same symptom: silence.

a) The WABA must subscribe to the app

Configuring the webhook in the panel is not enough:

POST /{waba-id}/subscribed_apps

[field] Without it, webhook verification passes, the panel says "connected successfully", and not one message arrives. There is no button for this in the UI.

curl -X POST "https://graph.facebook.com/v26.0/$WABA_ID/subscribed_apps" \
  -H "Authorization: Bearer $TOKEN"
# Verify with GET on the same endpoint.

⚠️ With a test WABA shared across apps in one portfolio, seeing "apps are subscribed" does not mean yours is. Cross-check against GET /app.

b) Webhook fields are subscribed separately

Different from the above. An app can be subscribed to the WABA and still have no messages field:

POST /{app-id}/subscriptions
     ?object=whatsapp_business_account
     &callback_url=...&verify_token=...&fields=messages

Requires an app access token (see §2).

[field] Changing the webhook URL can reset this. Re-check after any change.

c) The recipient must be allowlisted

[field] A test number only replies to registered numbers:

(#131030) Recipient phone number not in allowed list

The inbound message does arrive; the outbound reply fails. Add numbers in Step 1: Try it → To selector. Up to 5.


4. Webhooks

Verification (GET)

Meta sends hub.mode, hub.verify_token, hub.challenge. Return the challenge verbatim as plain text when the token matches; 403 otherwise.

Receiving (POST)

Validate X-Hub-Signature-256 — HMAC-SHA256 of the raw body with your App Secret. Two traps:

  • Sign the exact bytes received. If your framework re-serializes the JSON before you see it, the signature will never match — key order and whitespace differ.
  • Compare in constant time (hmac.compare_digest).

Respond 200 immediately

Meta retries on slow responses. Queue the work and return right away. Processing inside the handler causes retries and duplicate messages.

Deduplicate by wa_message_id. The same webhook can legitimately arrive more than once.

A payload without messages is not an error

Status events (delivered, read) arrive on the same webhook with field: "statuses". Your code must ignore them without breaking.


5. Flows

Publish by API, don't draw in the Builder

Keep the JSON versioned in your repo and publish it:

POST /{waba-id}/flows      → create (name, categories)
POST /{flow-id}/assets     → upload JSON (multipart, asset_type=FLOW_JSON)
POST /{flow-id}/publish    → publish

A form drawn by hand in the panel drifts from your code with nothing warning you: the response keys are a contract with your backend, and nobody is diffing them. With the JSON next to the code that reads it, changing a field is a reviewable diff.

POST /{flow-id}/assets returns validation_errors with line and reason — the only way to learn why a Flow won't publish.

Versioning is an operational concern

[verified] Flow JSON follows semver. Versions pass through two states:

StateWhat happens
FrozenCan't publish new Flows on it. Existing ones keep working.
ExpiredExisting Flows can no longer be opened.

[verified, 2026-09] Meta's stated policy: "In general, the period before freeze or expiry will be 90 days from the release of a new version" — while noting that "circumstances may require that a version be frozen or expired in less than 90 days." Third-party guides quoting a 12-month support window are stating something Meta's versioning page does not.

⚠️ That is a real lifecycle problem: a Flow's version will eventually expire, and updating a published Flow in place is risky (see the first gotcha below). Plan to move Flows to a new version periodically, and keep the JSON in source control so that re-uploading or recreating them is cheap.

[verified, 2026-09-03] Latest Flow JSON: 7.3, and it is the one Meta marks Recommended. The changelog's own "Updated" stamp reads 2025-11-07, so 7.3 has been current for roughly ten months — this claim is stable, not merely unchecked.

⚠️ The changelog renders in a browser but returns HTTP 500 to every programmatic client — old and new prefixes, with or without the anchor, curl with browser headers, archive mirrors. If you're scripting a freshness check, it will look permanently down. Open it in a browser instead.

Supported versions are two different lists

The table splits by what you're doing, and this is what "frozen" means in practice:

Flow JSON versions
Publishing a Flow5.1 · 6.0–6.3 · 7.0–7.2 · 7.3 (recommended)
Sending an existing Flow2.1 · 3.0 · 3.1 · 4.0 · 5.0 · 5.1 · 6.0–6.3 · 7.0–7.2 · 7.3 (recommended)

The five versions in the second list but not the first — 2.1, 3.0, 3.1, 4.0 and 5.0 — are the frozen ones: still sendable, no longer publishable. (5.0 is frozen while 5.1 is not, which matches its 2025-09-09 freeze.)

[verified, 2026-09-03] As of that date nothing has expired yet: everything back to 2.1 can still be sent. The expiry stage is real and worth planning for, but no Flow has actually stopped opening because of it.

The other two version numbers

Flow JSON is not the only version in play, and the skill's earlier focus on it alone was too narrow:

FieldCurrentNotes
data_api_version4.0 (recommended; 3.0 still supported)Adds two-signature auth for Flow endpoints
Message version3 (recommended)The flow_message_version in the send payload

[verified] Data API 4.0 (Nov 2025) strengthened endpoint security with two signatures: platform-side signature verification, handled by Meta with no work on your side, and an optional flow token signature added to the data channel payload, which you can verify to confirm a request really came from the user who received the Flow message. Opt in by setting data_api_version to 4.0 in the Flow JSON.

Gotchas

  • [verified, 2026-09-26] Uploading an asset to a published Flow sends it back to DRAFT. Meta accepts the upload without error, and from then on the Flow can no longer be opened with mode: "published" — a form that worked breaks silently (the customer taps the button and nothing happens). Any script that uploads assets must publish immediately afterwards. To recover: re-upload the previous JSON and POST /{flow-id}/publish. (Earlier versions of this file said a published Flow was immutable. That is false, checked against the live API.)
  • [verified] Every screen in a Flow must be reachable. Two forms opened from different buttons are two Flows, not two screens of one: Meta rejects the JSON with INVALID_ROUTING_MODEL.
  • [field] Sending an empty data: {} breaks the send. The error talks about the type when the real problem is that the field shouldn't be there:
    (#131009) Parameter data in flow_action_payload for CTA flow
              must be of type dynamic_object
    Omit data unless you actually have initial data.
  • [field] mode must match the Flow's state. A published Flow opens with mode: "published"; a draft only in draft mode. Mismatching gives an opaque error.
  • [field] input-type: "number" can come back unquoted. A bare isinstance(str) check drops the value silently. Accept numbers and coerce.
  • [field] flow_token returns unchanged in the webhook — use it to tie the response to the conversation that opened it. One per conversation, never a fixed value.
  • [field] response_json inside nfm_reply is a string containing JSON, not an object. Parse it separately.

6. Limits that bite

[verified]

LimitWhat happens if you exceed it
3 buttons per interactive messageMeta rejects the entire message
20 characters per button titleSame
10 rows per listTotal across sections, not per section
24 characters per row title
1024 characters of body
media_id expires after 30 daysThe file stops sending. Re-upload
24-hour windowOutside it you can only send approved templates. Inside it, replies are no longer unconditionally free since 2026-10-01 — see §7

Validate limits before calling the API. An over-limit payload returns a generic 400 that doesn't say which field was wrong; an explicit error in your own code saves the guessing.

On the 20-character limit: if you're tempted to put a person's name in a button, don't. Put the name in the message body — which has no such limit — and keep the button text fixed. Truncating "Message Alexandra" to "Message Alexan" looks like a bug to the customer, and it happens with names longer than about seven letters.


7. Pricing model

[verified, 2026-09] Pricing is per message, since 2025-07-01. This replaced the older per-conversation model — if you find a guide describing 24-hour "conversations" as the billing unit, it's out of date.

  • You're charged when a message is delivered. Incoming user messages are never charged.
  • Categories: Marketing, Utility, Authentication (templates), and Service (any non-template reply).
  • A 72-hour free entry point window applies when the user initiates contact through Click-to-WhatsApp ads or Page buttons.

The free 24-hour window ended on 2026-10-01

[verified, 2026-09-03] Documented on a separate page that the main pricing page does not surface: …/whatsapp/pricing/non-template-messages.

DateWhat changed
2026-08-01Meta charges for Meta Business Agent messages, per token, invoiced monthly.
2026-10-01Meta charges for service messages — "which have not been charged since November 2024".
2026-10-01Meta charges for utility templates sent in response to users inside an open 24-hour customer service window — "these messages have not been charged since July 1, 2025".
  • "Any non-template message that is not powered by Meta Business Agent is a service message." A free-form reply — from a human agent or your own bot, buttons, documents and Flows included — is a service message.
  • Service messages are priced like utility and authentication messages for the same country.
  • The 72-hour free entry point window still applies.

[unconfirmed, 2026-10-08] The first 1,000 service messages per business phone number per month are free, then each delivered one is charged. The allowance is per number (not per WABA), resets monthly and doesn't roll over. Reported consistently by BSPs (360dialog, Gupshup, respond.io, YCloud), who say Meta added it to its pricing page on 2026-09-01 — but Meta's non-template-messages page doesn't mention it. Per the same sources:

  • In-window utility templates don't count against the allowance — they are charged from the first one.
  • Several markets had utility/authentication rates raised on 2026-10-01 (and service follows utility). Look up your recipient country's current rate card instead of reusing an older figure.
  • Coexistence: messages a human sends from the WhatsApp Business app are not charged. Only what goes out through the API counts.

What this means in practice. A bot that only replies inside the window used to cost nothing. Now its cost is roughly (bot messages per conversation × conversations per month − 1,000) × the utility rate. The lever you control is messages per conversation: a greeting sent as text followed by a separate button menu is two billable messages, and folding the greeting into the menu's body is one. Count your bot's sends per typical conversation before estimating anything.

Why this was easy to get wrong. Meta splits current pricing and upcoming pricing across different pages. On 2026-09-03 the main pricing page correctly described the model then in force — non-template messages and in-window utility templates free, with "billable": false and "type": "free_customer_service" — with nothing saying it would change in four weeks. On 2026-10-08, a week after the change, scripted fetches of that page (old and new paths) still returned "service conversations are free". Never conclude anything about cost from that page alone: read the non-template-messages page, and confirm in WhatsApp Manager → Billing what you are actually being charged.

[verified] The On-Premises API was fully sunset on 2025-10-23. Cloud API is the only supported official architecture.


8. Coexistence and App Review

Coexistence lets one number work in the WhatsApp Business app and the Cloud API at once, with smb_message_echoes webhooks delivering what a human replied from the app.

Decision rule: does any human send messages from THAT number? → you need Coexistence. If the bot is the only thing living there, you don't.

[verified, 2026-09] In Meta's docs this lives under Embedded Signup → onboard WhatsApp Business app users, which is a Tech Provider flow. Becoming a Tech Provider requires Advanced access to both whatsapp_business_messaging and whatsapp_business_management — the first to send on behalf of clients, the second to reach their WABAs. Without it, calls against WABAs your business doesn't own fail with error 200.

[field] In practice that made the path:

Tech Provider  →  App Review  →  Advanced Access  →  App published (Live)  →  Coexistence

The Tech Provider prerequisite is the step people miss, because the panels let you connect the number, report success, and then deliver nothing.

The App Review procedure, in order

[field, 2026-08 and 2026-09] Done twice, for two apps in the same business portfolio. The first was approved on the first submission in 12 days.

The app Dashboard shows the path as a checklist. Labels below are translated from a Spanish-language panel, so match them by meaning:

Dashboard itemWhat it takes
Customize the WhatsApp use caseTest number working (§1, §3)
Facebook Login for BusinessThe Embedded Signup configuration (step 2 below)
Testing requirementsSuccessful API calls with the permissions you will request. Your bot running on the test number covers it
Business and access verificationBusiness verification is inherited by every app in the portfolio. Tech Provider is not (step 1)
App ReviewThe submission (steps 3–4)
Publish appAfter approval (step 5). The one people skip

1. Tech Provider is per app. Business verification carries over between apps of the same portfolio; the Tech Provider designation does not — each new app asks for it again, even when another app of the same business already has it. That's expected, not a misconfiguration.

2. The Embedded Signup configuration (Facebook Login for Business → Configurations → create). A six-step wizard that warns some steps can't be changed later — if you get one wrong, create another configuration.

StepChoose
NameOne that names the app and the use (Coexistence Sales): there will be several
Login variationWhatsApp Embedded Signup
ProductsOnly WhatsApp Cloud API
Access tokenExpiration Never
AssetsSelect nothing: the business grants them while going through the flow
PermissionsOnly whatsapp_business_management and whatsapp_business_messaging

The confusing part of the last step: the wizard mixes app permissions (the ones above) with task permissions on assets (manage, view). Different things; only the former matter.

It returns a config_id, which your onboarding page passes together with featureType: 'whatsapp_business_app_onboarding' (Coexistence). In the Facebook Login settings, also enable Login with the JavaScript SDK, add your page's domain to the allowed domains, and add an OAuth redirect URI even though the flow uses a popup — the app that worked had one. In App settings → Basic, the privacy policy, terms of service and data deletion URLs, the app icon (1024×1024) and the app domains must be filled in: the reviewer opens them.

⚠️ Don't confuse config_id with app_id. Same length, and they have been swapped before. The onboarding page needs both, and the app_id must match the one your token belongs to (debug_token returns it).

3. Advanced Access — there is no "Request advanced access" button any more. [field, 2026-10] Labels translated from a Spanish-language panel:

  • Use cases → Customize → Other tools → Permissions and features. The two WhatsApp permissions come pre-added, with status "Ready for testing" (Standard Access) and an Actions ▾ menu offering only Go to App Review and a greyed-out Required for use case (they can't be removed). Other permissions (business_management, email, manage_app_solution, whatsapp_business_manage_events) show + Add to App Review: leave them alone. public_profile is granted automatically.
  • Left menu → Review, with three sub-pages:
    • Testing: both permissions must read Completed. public_profile may stay at "N test API calls" without blocking anything. Tests are valid for 30 days and take up to 24 h to show — a bot that is actually running on the test number satisfies them on its own.
    • Verification: business verification and Tech Provider access.
    • App Review: where the submission is built and sent (step 4). Actions → Go to App Review lands in the same place.
  • Publish sits in the same left menu, labelled "Unpublished". It comes after approval (step 5).

4. The submission. [field, 2026-10] The screens, in order (labels translated from a Spanish-language panel):

  1. Review → App Review → "App Review requests", status Not submitted. Under New requests: whatsapp_business_messaging, public_profile and whatsapp_business_management, each with Usage rules, In 1 use case and a bin icon. Don't remove public_profile — every app has it, and a three-permission request was approved first try. Next.
  2. "Submit for App Review": five sections, each with a "Go to …" button. Verification and App settings are already green (inherited from the business and from App settings → Basic). Any of them opens the "App Review request" wizard: a 5-step bar, "Saved automatically", and Back to request · Previous · Next at the bottom.
  3. Allowed usage — one card per permission, "How will the app use …?".
    • The two WhatsApp permissions: first a one-line Business description (Get started stays grey until it's filled). Get started then opens a modal with:
      • ⚠️ an "AI Content Suggestion" with Use This. Don't use it: it invents generic features ("placing orders and tracking shipments", "access the business assets of our customers") and signs off with "[Your Name]". The reviewer compares it with the video.
      • the description box (how it's used / value / why it's needed),
      • Upload file for the screencast — one video can serve both permissions,
      • the test-call status, the acknowledgement checkbox, and Save. After saving, the video check stays grey while Meta processes the video, which can take a while; Meta notifies you when it's done.
    • public_profile: Get started opens a confirmation with only the checkbox — no description, no video.
  4. Data handling — arrives empty, with an "Autofill from another app" button: ⚠️ don't use it if the other app declares something this one doesn't do (an LLM provider, say). Five questions: processor-0 — processors or service providers, including your own companies, with access to Platform Data (yes/no; must match your privacy policy); responsible-1 / responsible-2 — the responsible legal entity and its country; requests-3 — personal data given to public authorities for national security requests in the last 12 months (no / yes by user ranges / prohibited by law); requests-4 — policies for such requests (legality review, challenging unlawful ones, data minimisation, documentation, none, prohibited by law). Tick only what the business actually does — it's a commitment.
  5. Reviewer instructions — ⚠️ a prerequisite nothing warns about earlier: with no platform on the app, this step only says you must specify platforms first. Fix: App settings → Basic → "+ Add platform" → Website → Site URL (the system the reviewer will use) → Save. Best done together with the legal URLs, before starting the request. Then the step asks for: the website login URL ("Edit website login URL"); instructions-web-2 (full instructions, in English); fblogin-web-1 "Is Facebook Login integrated on this platform?" (Yes if you use Embedded Signup — it's Facebook Login for Business); accesscode-web-1 (optional — put the test account's user and password here too); accesscode-web-2 (app-store gift codes) and geo-web-5 (geo restrictions). Don't leave the optional ones blank — answer each with one sentence ("Not applicable. This is an internal web application…", "None. Reachable from any location, no geo-blocking…"); that's how a first-try approval was filled in. Credentials must stay active for a year (Meta re-reviews periodically): don't deactivate the test account after approval. ⚠️ If you answer Yes to Facebook Login, the instructions must say what it's for — e.g. used once, by your own admin, for Embedded Signup, and not to sign users in (they use their own credentials). Otherwise the reviewer expects a "Continue with Facebook" button in the login shown in the video. Rule: before drafting any field, read what an already-approved submission from the same business said, and start from that.
  6. All five green: Submit for review (bottom right) opens a confirmation modal with a terms checkbox → Submit. It can't be edited or cancelled afterwards.
  7. Back on "App Review requests", the status reads "Review in progress" ("most requests are reviewed within 20 days"), with View request details. Meta emails the contact address from App settings → Basic.

What got it through on the first pass:

  • For each permission, three things: how it's used, what value it gives the user, and why it's necessary.
  • A screencast without narration, with title cards between shots explaining each one (reviewers watch without sound). It must show the Embedded Signup dialog up to the permissions screen — where whatsapp_business_management is visible — and real use of messaging. Close the dialog without completing it: connecting the real number comes after approval.
  • Reviewer instructions in English, and a test account with narrowly scoped access, with the full login shown in the video.
  • Data handling answers consistent with your privacy policy: who processes the data, which third parties (an LLM provider counts), how deletion works.
  • Published privacy policy, terms and data deletion pages. What they say is a commitment: the reviewer checks it against the video and against what the app does.
  • Don't reuse another app's instructions. An app that only reads and one that replies to customers aren't described the same way; copying asks Meta to approve something other than what the app does.
  • Once submitted it can't be edited or cancelled. Check everything before sending.

5. Approved: publish the app (next section).

6. Connect the number — and then redo the setup against what the flow created (the two sections after that).

Advanced Access is inert while the app is in Development mode

This is the trap that costs the most time, because it makes an approved permission behave exactly like a missing one.

[verified, 2026-09] "Apps in Development mode can only request permissions from role users." Development mode limits the app to people who hold a role on it (admins, developers, testers) no matter what App Review approved. Live mode is what makes Advanced Access mean anything for everyone else.

[field, 2026-08-31] Concretely: with all three permissions already showing Approved / Advanced Access, the Embedded Signup dialog still failed with

missing required Graph API permissions for Cloud API companion pairing

The permissions were not missing. The app was still unpublished. Flipping it to Live — with no change to permissions — made the same flow succeed on the next attempt. Read that error as "permissions not in effect", not "permissions not granted", and check app mode before re-submitting anything to review.

Embedded Signup creates its own WABA

[field, 2026-08-31] The flow created a brand-new WhatsApp Business Account for the number rather than using the empty WABA that had been pre-created and passed to it for exactly this purpose. Meta's docs don't promise either behavior — they describe connecting "their existing WhatsApp Business app account", which reads as though an existing WABA would be reused. It wasn't.

Consequences, all of which showed up in the same session:

  • Two WABAs ended up with near-identical names — the empty one created by hand and the one Embedded Signup made. In the pickers they are indistinguishable by name.
  • One of them was invisible to the app. Selecting the wrong one produced only:
    no es un identificador de negocio válido
    (not a valid business identifier)
    with nothing indicating which WABA was expected or why the other one didn't qualify.
  • Every post-connection step — subscribed_apps, assigning the system user, the WABA_ID in your config — has to point at the WABA the flow actually created, not the one you planned to use.

So: don't pre-create a WABA for a Coexistence number and assume it will be used. After the flow completes, read the WABA ID back from the API and treat that as the source of truth:

# WABAs owned by the business — find the one that actually holds the number
curl -s -H "Authorization: Bearer $TOKEN" \
  "https://graph.facebook.com/v26.0/{business-id}/owned_whatsapp_business_accounts"

# Confirm the number landed, and on Cloud API
curl -s -H "Authorization: Bearer $TOKEN" \
  "https://graph.facebook.com/v26.0/{waba-id}/phone_numbers?fields=display_phone_number,platform_type,status,is_on_biz_app"

platform_type must read CLOUD_API. [field] ON_PREMISE here is the value that fools people: the panel says connected, the number is listed, and nothing works.

After the connection: four steps, one symptom

[field] The flow finishing is not the end. Four things have to be redone against the new WABA and the new number, and skipping any of them produces the same silence — no error, no failed request, no log line.

  1. Assign your system user to the new WABA — and to the new number. They are two separate entries in the asset picker (§2): one lists WABAs, the other lists phone numbers. Assigning only the WABA is the common miss. Whatever your token could reach before, it cannot reach these — they didn't exist an hour ago.
  2. POST /{waba-id}/subscribed_apps on the new WABA. The subscription you made during development was for a different WABA and does not carry over. There is still no button for this (§3a).
  3. Update the IDs in your own config — WABA_ID, PHONE_NUMBER_ID. If they still point at the test number, your bot starts cleanly, logs nothing unusual, and processes none of the real traffic. Restart whatever caches them at boot.
  4. Send a real message from an allowlisted number and confirm it lands in your system. The panel is not evidence; a row in your database is.

The token itself usually does not need replacing — a system-user token doesn't expire. It just needs the assets from step 1.

Other things learned the hard way

  • Linked devices — [verified, 2026-10] onboarding unlinks every companion device; they can be re-linked afterwards, up to four. WhatsApp for Windows and WhatsApp for WearOS are not supported: they can't be re-linked, show placeholder text, and messages sent from them don't fire webhooks. Use WhatsApp Web in a browser (or the Mac app). [field] One team asked for its number to be disconnected two weeks after onboarding because "nobody could log back in to WhatsApp Web" — most likely they were on the Windows app. Before connecting a number, ask which devices the team uses, and re-link one with them present right after.
  • The 14-day rule — [verified, 2026-09] now documented, as an offboarding reason: PRIMARY_INACTIVITY (primary device inactive for approximately 14 days). If nobody opens the WhatsApp Business app for ~14 consecutive days, Meta stops syncing and can offboard the number. Tell the team that owns the phone; it reads as an outage later.
  • [verified, 2026-09] The phone must run WhatsApp Business app 2.24.17 or higher.
  • Coexistence cannot be tested with a test number. There is no dry run — the first real execution is on a number people depend on.
  • [field] The flow must be completed in one sitting, with the phone in hand. Interrupting it leaves the number half-registered and you have to clean up before retrying.
  • [field] App Review timing, one data point: submitted 2026-08-15, approved 2026-08-27 — 12 days, approved first try, against the 20 days Meta's own panel advertises. The "~5 business days" that circulates in third-party guides is wrong for App Review; it belongs to a different process. What made it go through in one pass was submitting complete: per-permission justification, screencast, data-handling declaration, published legal documents, and a scoped test account for the reviewer.
  • [field] Meta's automated support gave wrong answers repeatedly during one diagnosis — claiming a missing payment method, claiming misconfiguration, and denying features that exist. Verify against the API.

"Ready for testing" in Permissions and features = Standard Access: enough for the test number and for admins/developers of the app. A real number needs Advanced Access via App Review.

Don't request permissions you don't use — it complicates review, and the reviewer will ask what you need them for. A bot that replies to messages needs whatsapp_business_messaging and whatsapp_business_management.


9. Symptom → cause

SymptomMost likely cause
Panel says "connected", nothing arrivesMissing subscribed_apps, or missing the messages field
Session has expired (code 190)Temporary token expired
(#190) Application Secret requiredWrong token type — that endpoint needs an app token
(#131030) Recipient not in allowed listNumber not allowlisted on the test number
(#131009) must be of type dynamic_objectSending an empty data: {} in a Flow
(#131047) / "Re-engagement message"24-hour window closed — you need a template
Meta won't validate the callback URLThe URL returns 404 or 5xx — check your proxy before the token
Signature never matchesYou're signing re-serialized JSON, not the raw body
Duplicate messagesNot deduplicating by wa_message_id, or responding 200 too slowly
Flow opens, nothing happensFlow is a draft, or mode doesn't match
Flow suddenly stops openingIts Flow JSON version expired
Flow stops opening right after a script uploaded its JSONThe upload sent the published Flow back to DRAFT — publish it again (§5)
Charges appear for a bot that only replies in-windowExpected since 2026-10-01: service messages beyond the monthly allowance are billable (§7)
(#131042) Business eligibility payment issueNo valid payment method on the WABA. A WABA created by Embedded Signup doesn't inherit one — add it before the free allowance runs out
missing required Graph API permissions for Cloud API companion pairingOften not missing permissions — the app is still in Development mode, where Advanced Access is inert (§8)
"not a valid business identifier" during Embedded SignupWrong WABA picked — Embedded Signup made its own, and it's the near-identical twin (§8)
Coexistence number connected, platform_type: ON_PREMISEThe pairing didn't complete on Cloud API. It is not connected, whatever the panel says
Coexistence finished, still total silenceOne of the four post-connection steps was skipped — system user, subscribed_apps, config IDs, all against the new WABA (§8)
Coexistence number stopped syncingNobody opened WhatsApp Business for ~14 days (PRIMARY_INACTIVITY)
A doc link 404s, or contradicts the panelDocs moved to /documentation/business-messaging/whatsapp/; old paths 404 or serve a stale copy (§1)

Verify without trusting the panel

# Does the URL actually respond?
curl -s -o /dev/null -w '%{http_code}\n' https://YOUR-DOMAIN/your/webhook
# A 4xx from your framework = it exists. 404 = it never reaches your app.

# The full handshake, exactly as Meta does it
curl -s -G "https://YOUR-DOMAIN/your/webhook" \
  --data-urlencode "hub.mode=subscribe" \
  --data-urlencode "hub.verify_token=$VERIFY_TOKEN" \
  --data-urlencode "hub.challenge=test123"
# Must return exactly: test123

⚠️ A 200 proves nothing. If your domain serves a SPA with try_files … /index.html, every unknown path returns 200 with the SPA's HTML. Compare content, not status:

curl -s https://YOUR-DOMAIN/your/path | grep -oE '<title>[^<]*</title>'

This has already caused a broken deployment to be declared working.


10. Starting a new project

Order that avoids rework:

  1. App + test number. Don't touch a real number until the flow works — changing the webhook on a number in use interrupts service.
  2. Webhook: public HTTPS URL + verify token. Use a tunnel with a static domain while developing, so you don't re-point Meta on every restart.
  3. subscribed_apps + fields. Both. See §3.
  4. Allowlist your own number as a test recipient.
  5. Write a diagnostic command before the bot logic. It should check token, subscription, fields and local config, and say which one is missing. This is the single highest-leverage thing you can build, and you want it before you need it. See references/diagnose.py.
  6. Then the bot logic.
  7. Permanent token once the flow works — it needs another admin's approval, so request it early.
  8. App Review / Coexistence only when moving to a real number. Two things in that order, both easy to forget: get Advanced Access approved, then publish the app to Live — approval alone does nothing while the app sits in Development mode (§8).
  9. After a Coexistence connection, redo the setup against the new WABA. The flow may have created its own, and the system-user assignment, subscribed_apps, and the IDs in your config all have to follow it. See the four-step checklist in §8.

11. Working rules

  • Never print tokens or App Secrets in logs, transcripts or screenshots. To compare values without exposing them: ... | sha256sum | cut -c1-12.
  • Keep .env files and their copies out of the repo. A cp .env .env.bak that .gitignore doesn't cover is the most common way credentials leak. Cover .env* and add a pre-commit hook that scans staged content.
  • Deleting the branch doesn't delete the object. If a secret reached a push, the commit is still downloadable — rotate the credential.
  • Verify by content, not by status code.
  • Keep project IDs in the project's own repo, not in this skill. App ID, WABA ID, Phone Number ID and URLs differ per project and age fast.

Contributing

This is field knowledge from a small number of production deployments. If something here is wrong, out of date, or missing a case you hit, please open an issue — especially for anything tagged [field], which by definition has a sample size of one.

When Meta changes something, updating the last-verified date matters as much as the content: a stale skill is worse than no skill, because it sends people down the wrong path with confidence.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.