Skip to content

WhatsApp OTP — Meta Cloud API playbook ​

⚠ CREDENTIAL GUIDE. NO SECRET VALUE APPEARS IN THIS FILE, EVER.

Ids and account names are here because they are not secrets and hunting them again wastes a day. Tokens, PINs and app secrets live in Supabase secrets and the GitHub environment. Names only in git.

Status 2026-09-01: the channel WORKS. An OTP delivers to a real Indian handset in about a second, in English and Marathi, from the production number with the QR setu logo attached. What remains is the client side (the Edge Function and the Send SMS Hook) plus one Meta review we do not control.

Read this before touching WhatsApp on any QR setu app or environment. It exists because the first integration took a full day of trial and error across three Meta consoles, and almost none of that should ever be repeated. Everything below is measured through the Graph API rather than read off a dashboard.


1 · The live configuration ​

ThingValue
Business portfolioDigious · 3474521822707358
Meta appQR setu · 2026360431582199 · Unpublished, and that is fine (see 6.1)
System userqrsetuwhatsappsender · 61593624376718 · Admin
Production WABAQR setu · 2334317787305119
Production number+91 92703 73367 · Phone Number ID 1203827669491594
Display nameQR setu · name_status: PENDING_REVIEW
Test WABA1638609011050279 · number +1 555-196-5907 · PN id 1192903240583703
⛔ digious WABA27098697836465359 — not ours to use, see 5.1

Templates on the production WABA, all AUTHENTICATION, all APPROVED:

languagetemplate idbutton
en ⭐ the one R1 sends1387814936139183Copy code
mr1490184046281079कोड कॉपी करा
hi1075310198220143कोड कॉपी करें

message_send_ttl_seconds: 900 · code_expiration_minutes: 10

Business profile (what a recipient sees when they tap the sender): logo from apps/mobile/assets/images/icon.png, about Empowering Digital BHARAT, website https://qrsetu.com, vertical PROF_SERVICES, description naming Digious Platforms Private Limited.

⚠ Three phone numbers in this account differ by two digits. Read twice before debugging:

9270373367   production, Cloud API          <- the one that sends
9272373367   digious, WhatsApp Business app, dormant
9273373367   the test recipient handset

2 · Prerequisites, and which are queues you cannot rush ​

RequirementOwned byReality
Business portfolioyouminutes
Business verificationMetathe long one. Document upload plus review. Everything else is fast by comparison
A phone number with no WhatsApp account on ityousee 5.2
Payment methodyouper WABA, not per business (5.4)
Display name reviewMetahours to ~2 business days
App published—NOT required. See 6.1
App Review—NOT required for first-party OTP. Only for Tech Providers

🔎 Business verification is portfolio-level, not WABA-level. A new WABA created under a verified portfolio inherits business_verification_status: verified immediately. This matters enormously: it means creating a fresh WABA to escape a bad one costs minutes, not weeks. We used exactly that escape.


3 · The setup, in the order that actually works ​

⚠ This is not the order we discovered it in. Following it should take under an hour.

3.1 · System user and a permanent token — do this FIRST ​

The 24-hour token on the API Setup screen is for curl, not for a product. Wiring it works all day and then throws 401 every morning, looking exactly like a code defect.

  1. Business Settings → Users → System users → Add. Name it <app>whatsappsender. Role Admin.
  2. Assign assets → Apps → the Meta app → Full access.
  3. Assign assets → WhatsApp accounts → the WABA → Full access.
  4. Generate new token → the app → Expiry: Never → tick whatsapp_business_messaging andwhatsapp_business_management.
  5. Copy it immediately. Shown once.

⚠ Step 2 is the one people skip, and the error does not name it. With the Page and the WhatsApp account assigned but not the App, Generate token → Assign permissions reports "No permissions available". Apps then appear under Assigned assets as their own section, not under the separate Installed apps tab.

⚠ Do NOT add business_management to this token. It grants management of the entire business portfolio to a token whose only job is sending messages. The one call that needs it (/{business-id}/owned_whatsapp_business_accounts) is a convenience for discovering ids you can read off a URL instead.

Verify the token before going further:

bash
curl -s "https://graph.facebook.com/v22.0/debug_token?input_token=$T&access_token=$T"

Want: type: SYSTEM_USER · expires_at: 0 · both whatsapp scopes · is_valid: true.

3.2 · The production number ​

App Dashboard → Use cases → "Connect with customers through WhatsApp" → Customize → Step 2. Production setup.

⚠ Keep the "Integrate with API" tab. "Become a Partner" is for Tech Providers messaging on behalf of other businesses; it adds App Review and Embedded Signup for capability a first-party OTP never needs.

Step 2 walks: Configure Webhooks · Register phone number · Add payment · Send message. Registering the number creates a WABA if one is needed and inherits the portfolio's verification.

Set the display name to your product name here. It becomes verified_name and is what recipients eventually see.

⚠ Registering sets a 6-digit two-step PIN. Record it in the secret store immediately. It is needed whenever the number re-registers, and resetting a lost one has a multi-day cooldown.

3.3 · Per-WABA settings that block sending ​

Both of these are per WABA and neither carries over from another WABA in the same business.

  • Timezone. Absent, and every send fails with 141007.
  • Payment method. Absent, and 141006 blocks business-initiated conversations. An OTP is business-initiated, so this is mandatory, not billing housekeeping.

Billing is post-paid. The card is charged automatically at a threshold or monthly. There is no balance to load. The "1,000 free conversations" allowance is for service conversations where a customer messages first; authentication conversations are charged per message. ⚠ Do not hardcode any rate anywhere — the platform rule against depending on a provider fee applies here exactly as it does to Razorpay.

Check it took:

bash
curl -s -H "Authorization: Bearer $T" \
  "https://graph.facebook.com/v22.0/$WABA?fields=health_status"

Want can_send_message: AVAILABLE on all three entities: WABA, BUSINESS and APP.

3.4 · The template ​

Create it through the API. Authentication templates have a rigid shape and the UI hides half of it.

bash
curl -s -X POST "https://graph.facebook.com/v22.0/$WABA/message_templates" \
  -H "Authorization: Bearer $T" -H "Content-Type: application/json" -d '{
    "name": "qrsetu_otp",
    "language": "en",
    "category": "AUTHENTICATION",
    "message_send_ttl_seconds": 900,
    "components": [
      { "type": "BODY",   "add_security_recommendation": true },
      { "type": "FOOTER", "code_expiration_minutes": 10 },
      { "type": "BUTTONS",
        "buttons": [{ "type": "OTP", "otp_type": "COPY_CODE" }] }
    ]
  }'

Returns status: APPROVED immediately. Authentication templates skip the review queue entirely, so do not plan days around template approval.

⚠ You cannot write the body text, and that is the design. Meta owns the wording and supplies it per language. You choose only add_security_recommendation, code_expiration_minutes and the button.

💡 Which makes localisation free. Repeat the call with "language": "mr" and Meta supplies correct Marathi, written by its localisation team rather than translated by us. Create en, mr and hi regardless of which one you send: they cost nothing, and a template name is semi-reserved after deletion, so an unused row is cheaper than a future gap.

3.5 · Branding ​

An authentication template has no field for a tagline. The profile is where branding lives, and a profile picture renders beside every message in the chat list and in the notification, which body text never does.

bash
# 1. session
curl -s -X POST -H "Authorization: Bearer $T" \
  "https://graph.facebook.com/v22.0/$APP/uploads?file_length=$BYTES&file_type=image/png"
# 2. bytes  -- OAuth, NOT Bearer. The only call in this integration that differs.
curl -s -X POST -H "Authorization: OAuth $T" -H "file_offset: 0" \
  --data-binary @icon.png "https://graph.facebook.com/v22.0/$SESSION_ID"
# 3. attach + the rest of the profile
curl -s -X POST "https://graph.facebook.com/v22.0/$PN/whatsapp_business_profile" \
  -H "Authorization: Bearer $T" -H "Content-Type: application/json" -d '{
    "messaging_product":"whatsapp","about":"Empowering Digital BHARAT",
    "websites":["https://qrsetu.com"],"profile_picture_handle":"<h>"}'

Use a 1024x1024 PNG. Ours is apps/mobile/assets/images/icon.png.

3.6 · Send ​

json
POST /{phone-number-id}/messages
{
  "messaging_product": "whatsapp",
  "to": "919999999999",
  "type": "template",
  "template": {
    "name": "qrsetu_otp",
    "language": { "code": "en" },
    "components": [
      { "type": "body",   "parameters": [{ "type": "text", "text": "123456" }] },
      { "type": "button", "sub_type": "url", "index": "0",
        "parameters": [{ "type": "text", "text": "123456" }] }
    ]
  }
}

⚠ The code appears TWICE — body parameter and URL button parameter. Omitting the button parameter is the usual 132000.

⚠ The button is sub_type: "url". copy_code with a coupon_code parameter reads like the natural shape for a copy-code button and is rejected: 132018 Button at index 0 must be of type Url.

⏱ Wait a few minutes after creating a template before the first send. See 5.5. This one cost hours.


4 · Environment and configuration ​

VariableWhatWhere from
WHATSAPP_ACCESS_TOKENsystem user token, never expires3.1
WHATSAPP_PHONE_NUMBER_ID⚠ SUPERSEDED — do not set. Routing comes from whatsapp_phone_numbers, seeded by migration 20260901140000. Env holds the credential; the registry holds the routing, or the platform is capped at one number forever—
WHATSAPP_WABA_ID⚠ SUPERSEDED — do not set. Same reason; whatsapp_business_accounts holds it—
WHATSAPP_APP_SECRETverifies X-Hub-Signature-256 on inboundApp settings → Basic
WHATSAPP_OTP_TEMPLATE⚠ SUPERSEDED — a constant in send-auth-otp. The registry records the binding as used_by = ['auth.otp']; an env var would be a second place it could change, able to disagree silently—
WHATSAPP_OTP_TEMPLATE_LANG⚠ SUPERSEDED. The adapter resolves an APPROVED translation and falls back, because a template can be APPROVED in en and REJECTED in mr and a sign-in must not fail over a copy problem—
WHATSAPP_WEBHOOK_VERIFY_TOKENwe invent this, not fetchedour choice
SEND_SMS_HOOK_SECRETthe Standard Webhooks secret. ⚠ See the danger box above — it cannot be read backset once, to both places
WHATSAPP_TWO_STEP_PINthe number's 6-digit registration PIN3.2

⚠⚠ THE HOOK SECRET CANNOT BE READ BACK — SET IT ONCE, TO BOTH PLACES, IN ONE OPERATION

hook_send_sms_secrets is returned MASKED by the Management API. PATCH it with v1,whsec_<base64> (53 chars) and GET it back: you receive 64 characters matching ^[0-9a-f]{64}$ — a SHA-256 digest, not the secret (QRS-942).

So the obvious workflow is the broken one. The secret has to live in two places — the project's auth config, and the Edge Function's SEND_SMS_HOOK_SECRET — and the natural way to fill the second is to read the first. Every key derived from that read-back is derived from a hash, so verification fails on every request with no_matching_signature, which points at the algorithm, the encoding, or an attacker, and never at the value.

The correct sequence — generate once, write twice, never read:

  1. Generate v1,whsec_<standard base64 of 32 random bytes>.
  2. PATCH /v1/projects/{ref}/config/auth with hook_send_sms_secrets.
  3. Set the same in-memory value as the SEND_SMS_HOOK_SECRET function secret.
  4. Verify by triggering a real signInWithOtp, never by comparing values.

⚠ A self-signed probe cannot detect this. If your test signs requests with the same value the verifier uses, the two agree with each other while GoTrue disagrees with both. For a signature scheme, the only test that means anything is one where the counterparty produced the signature.

⚠ And a character-class check will not spot it either: hex is a subset of the base64 alphabet, so ^[A-Za-z0-9+/=]+$ matches a digest happily and it "decodes" to plausible-looking bytes.

⚠⚠ IT IS NOT JUST THIS FIELD — GET /v1/projects/{ref}/secrets MASKS EVERY VALUE (QRS-947). Measured across all 21 secrets on Dev: every one comes back as 64 characters matching ^[0-9a-f]{64}$, whatever its real length or format. So no secret can be read back from that endpoint, ever — a script that reads one to configure something else configures it with a hash.

The tell is the length. A service-role JWT is 219 characters; 64 is not a truncation of it, it is a different kind of thing. This cost a second debugging round in the same session as the first, because a 401 from the admin API reads as a permissions problem.

✅ API keys have a real read path: GET /v1/projects/{ref}/api-keys — anon and service_role as JWTs, plus sb_publishable_… and sb_secret_…. Every other secret has none: hold the value from the moment it is generated, or generate a new one.

⚠ AUTH HOOKS RUN INSIDE GOTRUE'S UNCOMMITTED TRANSACTION

The hook fires with a user.id that is not yet visible in public.users from any other connection (QRS-943). Supabase documents that hooks "are run in a transaction"; an Edge Function connects on a separate session and cannot see the row.

This is permanent and universal — it happens on every new user's first OTP, which is the most common path in the product. Any write the hook performs that references public.users must tolerate the row being absent, or consumer onboarding is broken for every new account.

Set them per environment, never in a command line:

bash
npm run sb -- dev secrets set WHATSAPP_ACCESS_TOKEN

.env.example gains the names in the same change, since it is the reference for a new machine.

⚠ SWEEP TWO STALE KEYS IN THE SAME CHANGE

.env.example still carries VITE_SUPABASE_URL / VITE_SUPABASE_ANON_KEY, left by the retired Vite SPA. They have zero hits anywhere in the tree and are superseded by EXPO_PUBLIC_SUPABASE_* for apps/mobile and SUPABASE_* for apps/web. Remove them when the WhatsApp names go in, since that file is what a new machine is set up from.

Supabase side ​

Architecture: the Send SMS Hook, not our own OTP. Supabase generates, stores, expires and verifies the code; our Edge Function only delivers it. Minting our own codes would mean owning hashing, expiry, replay protection and rate limiting, and auth.users.phone_confirmed_at would never be set correctly.

⚠⚠ THE TWO EXPIRIES MUST BE THE SAME NUMBER, AND THEY LIVE IN DIFFERENT SYSTEMS. WhatsApp greys the code out client-side after code_expiration_minutes; Supabase decides independently whether verifyOtp still accepts it. Longer on Supabase and a person is told a working code is dead and gives up. Shorter and they type a code the message presents as live and are refused. Set Supabase OTP expiry to 600 seconds to match code_expiration_minutes: 10, and treat the pair as one setting that happens to live in two places.

Still unverified and it must be checked before the seam is built (QRS-910): does signInWithOtp({ phone, options: { data } }) write raw_user_meta_data the way the email path does? If not, handle_new_user applies its 'business' default and every consumer is provisioned as a merchant.

Also owed: QRS-921, rate limiting, which does not exist anywhere in this platform. An OTP send costs money per message, so an unthrottled endpoint is a direct drain. Two independent limits, per phone number and per source; either alone is trivially bypassed.


5 · Traps, each of which cost real time ​

5.1 · A WABA bound to the WhatsApp Business app cannot host Cloud API numbers ​

Symptom: Add phone number is greyed out in WhatsApp Manager. Cause: the WABA (digious) was created through the consumer WhatsApp Business app. Its number reports platform_type: ON_PREMISE, status: DISCONNECTED, and subscribed_apps is empty. Fix: do not migrate it. Migrating means deleting the number from the WhatsApp Business app first, which destroys that number's chat history permanently and ends its use in the app forever. Create a new WABA through the app's use-case Step 2 instead; it inherits business verification and costs minutes.

🔎 The generalisable point: WhatsApp Manager is asset MANAGEMENT. Number registration is ONBOARDING, and onboarding lives in the app's use case. We spent several rounds in the wrong console.

5.2 · A number that already has WhatsApp cannot be registered ​

Uninstalling the app is not enough — the registration survives on Meta's side. Delete the account from inside WhatsApp (Settings → Account → Delete my account), which erases that number's chat history. Afterwards the number can never be used in the WhatsApp app while it is on Cloud API.

A fresh SIM avoids the whole decision for a few hundred rupees. We used a different number rather than burning one in use.

5.3 · The Meta dashboard is use-case based; there is no product sidebar ​

Symptom: no "WhatsApp" entry anywhere in the app's left nav, so the documented WhatsApp -> API Setup path does not exist. Cause: newer apps use a use-case dashboard: Dashboard · Required actions · Use cases · Testing · Publish · App settings · App roles. Fix: everything WhatsApp is under Use cases → Connect with customers through WhatsApp → Customize. Ids are also in URLs: ?business_id= on the dashboard, asset_id= in WhatsApp Manager.

5.4 · Payment and timezone are per WABA ​

Adding a card to one WABA does nothing for another in the same business. Both 141006 (payment) and 141007 (timezone) block sending, and both are per WABA.

5.5 · ⚠ The first send after creating a template fails silently ​

Symptom: the API returns 200 accepted with a wamid and nothing arrives. No error anywhere. Cause: template propagation. The send went out seconds after creation. Fix: wait a few minutes. Every later send worked.

🔎 status: APPROVED on a freshly created template does not mean it is sendable yet, and this cost more time than anything else in the integration: hours went into debugging a payload that was correct throughout, because the failure looked identical to a malformed request.

5.6 · accepted is not delivered, and without a webhook you are blind ​

message_status: accepted means Meta queued it. Delivery and failure reasons arrive only over the webhook. Until it is configured, a failing send is indistinguishable from a succeeding one.

The one diagnostic that works without a webhook: WhatsApp Manager → Insights → Message pricing. It breaks delivery down by category, which is what told us Utility delivered while Authentication did not, narrowing the problem to the template rather than the account, the number, the token or the app mode, all of which would have broken both.

5.7 · The display name shows as the raw number until Meta approves it ​

name_status: PENDING_REVIEW means WhatsApp has no approved name and falls back to the number. There is no setting and no way to expedite. Hours to ~2 business days.

⚠ A device caches contact info, so it can lag after approval; force-closing WhatsApp refreshes it. And if the recipient has the number saved as a contact, WhatsApp shows the contact name and ignores the business name entirely.

⚠ Risk worth knowing at submission time: a display name that is a brand rather than the legal entity (here QR setu vs Digious Platforms Private Limited) can be rejected. Put the website on the profile before submitting; that is the connection a reviewer looks for.

5.8 · Smaller ones ​

  • A browser address bar sends no token. Pasting a Graph URL gives 104 An access token is required. Use the Graph API Explorer, or curl with the token as a header so it stays out of shell history.
  • The Phone Number ID is not the phone number. Sending the number gives a 404 that reads like the number is not registered.
  • data: [] is an answer, not a failure — usually a missing asset assignment rather than a missing account.
  • Profile picture upload step 2 wants Authorization: OAuth, not Bearer.

6 · Findings that changed the plan ​

6.1 · ✅ App Review is NOT a launch prerequisite ​

A template message delivered to a real Indian handset with no role on the app and on no allowlist, while the app was Unpublished. This was rated a P0 schedule risk (QRS-919) on the assumption that publishing gated delivery. It does not. The 5-recipient allowlist is a property of the test number, not of production numbers.

6.2 · Meta's Template Library exists, but composing from it is refused ​

Richer pre-authored authentication copy exists (e.g. verify_account, proven to be a real name: an invented one returns 2388222 Library template not found while verify_account passes that check). But any components array is rejected with 2388220 Library template-based HSM creation with custom content is not allowed — the body and buttons are already configured. Adopt from the UI: WhatsApp Manager → Message templates → Template library.

⚠ Pick for meaning, not for the brand slot. The much-admired GoPay sample says "linking it to merchant_name" because it is a wallet attaching to a third-party merchant. A template promising an account link that never happens is worse than a generic one.

🔎 And weigh whether it is needed at all: the brand is already on the message twice — sender name and logo, both visible in the notification before the body is read.

6.3 · ONE_TAP autofill is the better CTA and is blocked on something else ​

COPY_CODE is a button the reader taps; ONE_TAP drops the code straight into the app. It needs the Android package name and the SHA-256 signing hash, and QRS-908 has release signing on the debug keystore, so that hash is not stable. One-call upgrade once it is real.

6.4 · Approaches tried and rejected, with reasons ​

ApproachWhy not
Migrate the existing ON_PREMISE numberDestroys chat history permanently; a new number costs nothing
Add business_management to the sending tokenPortfolio-wide management for a one-time id lookup
GET /{waba}/template_library and /message_template_libraryNeither edge exists (2500, 100)
library_template_name + components2388220, custom content not allowed
sub_type: "copy_code" on the send132018, must be type Url
A UTILITY template to get custom copyLoses the copy-code button and auto-localisation, different rate, and Meta re-categorises OTP content, risking the template and the account
Our own OTP generationWould own hashing, expiry, replay and rate limiting, and never set phone_confirmed_at correctly

7 · Testing and verification ​

Order matters: check health before blaming the payload.

bash
# 0. token sane?
curl -s "https://graph.facebook.com/v22.0/debug_token?input_token=$T&access_token=$T"
# 1. can the account send at all?
curl -s -H "Authorization: Bearer $T" "https://graph.facebook.com/v22.0/$WABA?fields=health_status"
# 2. is the number live?
curl -s -H "Authorization: Bearer $T" \
  "https://graph.facebook.com/v22.0/$PN?fields=display_phone_number,verified_name,platform_type,status,code_verification_status,quality_rating,name_status"
# 3. do the templates exist and in what state?
curl -s -H "Authorization: Bearer $T" \
  "https://graph.facebook.com/v22.0/$WABA/message_templates?fields=name,language,category,status"
# 4. send  (see 3.6)

Want at step 2: platform_type: CLOUD_API · status: CONNECTED · code_verification_status: VERIFIED.

Then read Insights → Message pricing, which is the only delivery evidence available before the webhook exists. It splits delivered counts by category.

Edge cases worth exercising ​

CaseExpected
First send right after template creationmay silently fail (5.5)
Recipient has no WhatsAppneeds its own UI state; the design covers it as nowhatsapp
Code entered after 10 minutesWhatsApp shows "This code has expired" and drops the copy button. Supabase must agree
Plain text with no open 24h windowaccepted by the API, then dropped. Only templates work business-initiated
Recipient saved the sender in contactscontact name wins over the business name
Same code re-sentfine; Meta does not dedupe

8 · What is still open ​

ItemOwner
name_status: PENDING_REVIEW — sender shows as the numberMeta's queue. Escalate only after ~2 business days
Webhook callback URL unset, so no delivery receiptsus, with the Edge Function
Send SMS Hook + Edge Function not builtus, next
Supabase OTP expiry not yet aligned to 600sus, with the hook
signInWithOtp({ phone }) metadata unverified (QRS-910)one probe, before the seam is built
Rate limiting (QRS-921)us
ONE_TAP upgrade, gated on QRS-908after the keystore

9 · Chronology, kept because the order of discovery is itself a lesson ​

Condensed from QRS-932, which holds the measurements.

  1. Owner reports the Meta app created and "all approved". True of business verification; nothing else was ready — a reasonable reading of a dashboard that shows green ticks for use-case setup.
  2. Guide written pointing at WhatsApp -> API Setup. Wrong twice: that screen needs the product added, and this app uses the use-case dashboard where no product sidebar exists.
  3. Token generation blocked on "No permissions available" — the App asset was unassigned.
  4. WABA measured: can_send_message: BLOCKED on payment and timezone; number ON_PREMISE and DISCONNECTED; zero templates.
  5. Add phone number disabled, so a new WABA was created via use-case Step 2, inheriting verification. Destructive migration avoided.
  6. Templates created via API in en/mr/hi, APPROVED instantly.
  7. First OTP send: accepted, never arrived. Four rounds of payload debugging followed. The payload was correct; it was template propagation.
  8. hello_world delivered, proving the transport and that Unpublished does not gate delivery.
  9. Insights by category isolated the failure to AUTHENTICATION while Utility delivered.
  10. Later OTP sends delivered in en and mr, ~1s dispatch, logo attached, display name still pending.

🔎 The two lessons worth carrying to any future integration: an APPROVED template is not immediately sendable, and without the webhook you cannot tell a failure from a success, so configure it before you need it rather than after.