# Building a WhatsApp Cloud API Integration

A language-agnostic walkthrough of what you have to handle to let businesses receive and answer WhatsApp messages through your own application: onboarding their phone numbers, subscribing to webhooks, verifying signatures, parsing inbound deliveries, sending replies, and keeping the access token alive.

The API surface here is the WhatsApp Cloud API on graph.facebook.com/v26.0, current since 2026-07-29, with numbers onboarded through Embedded Signup. The retired On-Premises API and the resale layers that Business Solution Providers build on top of either are out of scope. Where WhatsApp and Instagram solve the same problem in opposite ways, this article names the Instagram behaviour so you can tell which habits carry over from that integration.

## 1. Who this is for

You are building a customer-communication app, a help desk or a CRM or a chatbot, and you want operators in your app to answer WhatsApp messages sent to connected business numbers. Customers write from the WhatsApp already on their phone. Your app needs those messages in its UI within a second or two, your operators reply from your UI, and the reply lands in the customer's WhatsApp.

The Cloud API covers that: two-way messaging on business phone numbers, with media, delivery receipts and read receipts. It leaves out:

Design around the third exclusion. A business-initiated conversation needs a message template approved by Meta in advance, and templates are a surface of their own: categories, variables, per-language versions, review, pricing. Meta's template documentation covers them. Everything below assumes the customer wrote first, which is the shape of a help desk.

You will need a Meta Developer app of type Business with the WhatsApp product enabled, a Meta Business Portfolio that has completed Business Verification, valid privacy-policy and terms-of-service URLs, and app review granting Advanced access to whatsapp_business_messaging and whatsapp_business_management.

Two gates to clear before starting:

Both gates take weeks of calendar time, and neither starts after the code works. Business Verification wants documents about a legal entity. App review wants the deauthorize and data-deletion callbacks of §5.6 live and answering.

The six concerns we cover, in order:

  1. Onboarding: an access token and the ids of the numbers it covers.
  2. Registering each number so it can send and receive.
  3. Subscribing the business account to webhook deliveries.
  4. Receiving and validating inbound webhook events.
  5. Sending outbound messages.
  6. Keeping the token alive, or noticing when it dies.

Two further sections cover the health of the phone number, which has no Instagram equivalent, and the operational concerns that production deployments need.

## 2. Architecture overview

                ┌──────────────────┐
   Browser ───▶│ Embedded Signup  │ ──▶ Token store (DB, one row per phone number)
                └──────────────────┘
                         │
                         ▼
                ┌──────────────────┐
                │ Register number  │ ──▶ Subscribe its WABA to webhooks
                └──────────────────┘

   Meta servers ──▶ Webhook receiver ──▶ Inbound queue ──▶ Inbound consumer
                          │                                      │
                          └─ verifies signature                  ▼
                                                        Persist message,
                                                        enqueue media download

   Operator UI ──▶ API ──▶ Outbound queue ──▶ Outbound consumer
                                                   │
                                                   ▼
                                    POST graph.facebook.com /<phone-number-id>/messages

Two queues, not one. A webhook receiver has to answer 200 within a few seconds, and a slow media download must not hold up the message it belongs to. When the downloader falls behind, the operator still sees the text and the caption.

A channel is a phone number. One login can hand you several business accounts with several numbers under each. The unit an operator connects, answers from and disconnects is a single number, so that is the row, and this article calls it a channel. The account above it matters only for the subscription.

Onboarding also hands you four opaque strings. They are all numeric and all look alike, and writing one into the column meant for another produces errors that name neither.

IdentifierWhat it namesWhat it does
phone_number_id One business number The path segment of every send and the routing key of every inbound delivery. Your channel's external id.
waba_id The WhatsApp Business Account that owns the number What you subscribe to webhooks. One subscription covers every number under it.
user_id The person who went through the dialog, scoped to your app The only thing the deauthorize and data-deletion callbacks tell you (§5.6). Store it at connect time or those callbacks have nothing to match.
wa_id The customer Their phone number in bare digits, no + and no separators. The recipient of every reply and the external id of your client record.

## 3. Prerequisites and configuration

Four values from the Meta dashboard go into your application configuration:

Env varWhat it is
WA_APP_IDPublic ID of the Meta app that owns the WhatsApp product
WA_APP_SECRETSecret of that same app
WA_CONFIG_IDThe Login Configuration the Embedded Signup dialog opens with (§4.1)
WA_WEBHOOK_VERIFY_TOKENA long random string you choose for the webhook handshake

Both app values sit on Settings → Basic. The configuration id is under WhatsApp → Configuration → Login configurations, and you set the verify token on the same page when you save the callback URL.

This is a plain Meta app, unlike Instagram's. An Instagram integration reads its id and secret off a nested Instagram app with credentials of its own, and pasting the outer Meta values there fails. WhatsApp has no such nesting: the app id and secret on Settings → Basic are the ones every call in this article wants. If you are building both integrations, they are two separate Meta apps with two separate secrets, and WhatsApp is the flat case.

The two hosts

Both carry the version segment, and the same v26.0 works in both. Instagram splits its flow across three origins with one of them versioned.

Verify token vs. app secret

The most common bug when implementing Meta webhooks is conflating the verify token with the App Secret. They are different secrets used in different requests.

SecretUsed by Meta to ...Used by you to ...
WA_WEBHOOK_VERIFY_TOKEN Echo it back during the GET challenge, each time a callback address is saved or overridden Compare-equal during that challenge, which is its only job
WA_APP_SECRET Sign every POST webhook body with HMAC-SHA256, and accept it on the code exchange (a) Verify X-Hub-Signature-256 on POST webhooks, (b) build the app access token, (c) exchange the authorization code

The handshake is not the one-time setup step it is on Instagram. Every override_callback_uri call (§5.3) makes Meta verify the address again, so the challenge handler stays live for the lifetime of the integration.

HTTPS is mandatory on the webhook URL, and Meta checks that the address answers before accepting it.

Every Graph call authenticates with a header. Authorization: Bearer <token> on calls made with a business's token, and Authorization: Bearer <app-id>|<app-secret> on the app-level calls of §5.2. Graph also accepts ?access_token=, and taking it up puts a live credential into every access log, HTTP client trace and exception message that prints a URL. The exception message is the one that bites: a failing call is the moment something writes the URL somewhere.

Local webhook testing tip: use a tool like Cloudflare Tunnel or ngrok to expose your local server over HTTPS. Whatever address you hand Meta also has to be the address your attachment links resolve to (§7.3), because Meta fetches those from its own servers.

## 4. Onboarding a number

### 4.1 The Embedded Signup dialog

Onboarding runs in the browser through Meta's JavaScript SDK, and no redirect flow drives it from the server. The dialog walks the business owner through picking or creating a WhatsApp Business Account, adding a phone number, verifying it by SMS, and granting your app permissions on it. What comes back to you is an authorization code.

FB.login(callback, {
  config_id: WA_CONFIG_ID,
  response_type: 'code',
  override_default_response_type: true,
  extras: {
    setup: {},
    featureType: '',              // see below
    sessionInfoVersion: '3',
  },
});

override_default_response_type makes the dialog return a code. Without it you get an access token in the page, which is the wrong place for one.

featureType selects the onboarding flow. Empty is the standard one, where the number moves onto the Cloud API. whatsapp_business_app_onboarding is Coexistence, where the number keeps working in the owner's WhatsApp Business app (§6.9). You make that choice here, before the dialog opens.

Alongside the callback, the dialog posts message events to the opener carrying the waba_id and phone_number_id the owner picked. Use them for progress, and treat them as untrusted: they come from the browser, which is to say from whoever sits in front of it. Resolve both from the token (§4.3, §4.4).

### 4.2 Exchange the code

Your frontend posts the code to your backend, which exchanges it server-side:

GET https://graph.facebook.com/v26.0/oauth/access_token
      ?client_id=<WA_APP_ID>
      &client_secret=<WA_APP_SECRET>
      &code=<code>

This exchange takes no redirect_uri. The SDK flow has no redirect, and sending one anyway is rejected as a mismatch.

{
  "access_token": "EAAG...",
  "token_type": "bearer",
  "expires_in": 5183944
}

expires_in is seconds, and it may be absent. Its absence carries meaning rather than signalling a malformed response: a token with no deadline reports no expiry. Store NULL and read it as "no deadline" (§8).

### 4.3 Ask the token what it covers

The token names its own scope. Introspect it:

GET https://graph.facebook.com/v26.0/debug_token?input_token=<the new token>
Authorization: Bearer <WA_APP_ID>|<WA_APP_SECRET>
{
  "data": {
    "app_id": "...",
    "type": "USER",
    "user_id": "1234...",                     // app-scoped — store it, §5.6
    "expires_at": 1772000387,
    "data_access_expires_at": 1774592387,     // a second deadline, §8.4
    "granular_scopes": [
      { "scope": "whatsapp_business_messaging",   "target_ids": ["1789..."] },
      { "scope": "whatsapp_business_management",  "target_ids": ["1789..."] }
    ]
  }
}

The target_ids under whatsapp_business_management are the WhatsApp Business Accounts this token may act on, which is to say the accounts the owner selected in the dialog. One request answers "which WABAs did I get" and hands you the two values that §5.6 and §8 need.

Do not walk /me/businesses to find the accounts. It is the obvious traversal and it goes wrong twice over. It reaches every business portfolio the person belongs to, including ones they never granted you and which Graph answers 403 for. A business that owns its account rather than having it shared with a partner also turns up under a different edge, so a walk that looks complete against your own test account returns an empty list for the first real customer.

### 4.4 Enumerate the phone numbers

For each account id from the previous step:

GET https://graph.facebook.com/v26.0/<waba-id>/phone_numbers
Authorization: Bearer <access token>
{
  "data": [
    {
      "id": "1270...",                      // phone_number_id — your channel key
      "display_phone_number": "+1 555 0100",
      "verified_name": "Acme Support",
      "quality_rating": "GREEN",
      "code_verification_status": "VERIFIED",
      "platform_type": "CLOUD_API"
    }
  ]
}

display_phone_number is formatted for humans and is the only form of the business number you get here. A wa_id is bare digits, so the two never compare equal; do not use either to decide who sent what (§6.9 is where that temptation appears).

verified_name is what customers see as the sender. Show it to the operator next to the number, because a business with three numbers tells them apart by that name and not by digits nobody reads.

### 4.5 Register the number

A number in that list cannot yet send or receive. Registration is a separate call, and a first implementation tends to miss it, because everything else succeeds without it and the failure surfaces much later as a refused send:

POST https://graph.facebook.com/v26.0/<phone-number-id>/register
Authorization: Bearer <access token>
Content-Type: application/json

{ "messaging_product": "whatsapp", "pin": "123456" }

The pin is the number's six-digit two-step verification PIN. If the number has never had one, whatever you send becomes it. If it has one, because the business set it up in the WhatsApp Business app or because you registered the number before, you have to send that same value and a wrong one is rejected.

So generate a PIN per number, store it, and reuse it on every later registration of that number. Without it, nobody can re-register the number after a disconnect unless they remember what was typed the first time, which in practice means a support ticket and a reset through Meta.

Code 133010, "not registered", is a send-time error with an onboarding-time cause. Nothing in the connect flow complains, the channel looks healthy, inbound may arrive, and every reply is refused. On a channel that used to work it means the number was taken over elsewhere: registration is exclusive, and the last platform to register wins.

POST /<phone-number-id>/deregister releases the number. It belongs on your disconnect path rather than your delete-channel path. Deleting a channel in your app is a local act; deregistering makes the number unusable on the Cloud API until somebody registers it again.

### 4.6 Persist the channel

One row per phone number:

ColumnValue
phone_number_idFrom §4.4. The external id the webhook routes on.
waba_idThe account it lives under. Needed to subscribe and unsubscribe.
facebook_user_idThe app-scoped user_id from §4.3.
display_phone_number, verified_nameFor the operator's benefit. Refresh them on reconnect.
access_tokenEncrypted at rest.
access_token_expires_atFrom expires_in, or NULL for a token with no deadline.
two_step_pinEncrypted. From §4.5.

Reconnecting an existing number updates the row instead of creating a second one. Match on phone_number_id among live channels, and if the match belongs to a different tenant, refuse the whole login. A number carries its chat history, and handing it to whoever logged in most recently hands them somebody else's conversations.

Resist enforcing that with a unique index on phone_number_id alone. A soft-deleted channel still occupies the value, so the index refuses the reconnect of a number that was disconnected on purpose, and it refuses with a constraint violation rather than anything a person can act on. Scope the uniqueness to live rows if your database can express that, and otherwise let the lookup and the tenant check carry it.

### 4.7 One login, several numbers

The steps above are a loop. A business with three numbers across two accounts goes through the dialog once and comes back with five things to create.

## 5. Subscribing to webhooks

Three things have to be true before a message reaches your handler: your app is subscribed to the WhatsApp object, the business account is subscribed to your app, and the fields you want are ticked in the dashboard. They live in three different places, two of them reachable by API and one of them not, and a gap in any of the three looks the same from outside: a connected channel that receives nothing.

### 5.1 The verify-token handshake (GET)

Whenever a callback address is saved, Meta calls it once with a challenge:

GET /your/webhook?hub.mode=subscribe&hub.challenge=1158201444&hub.verify_token=<yours>
def webhook_get(request):
    if (request.query["hub.mode"] == "subscribe"
            and constant_time_equal(request.query["hub.verify_token"], config.WA_WEBHOOK_VERIFY_TOKEN)):
        return Response(request.query["hub.challenge"], content_type="text/plain")
    return Response(status=403)

Echo the challenge as a bare string. Wrapping it in JSON fails the check, and the dashboard reports it as an unreachable URL.

Some platforms (notably PHP) translate . to _ in query parameter keys. If you read hub_mode instead of hub.mode, that's why; both refer to the same parameter on the wire.

### 5.2 Subscribe the app to the object (once, ever)

Before any business account can be subscribed, the app itself has to be subscribed to the whatsapp_business_account webhook object. This is an app-level setting made with an app access token, and it writes what the App Dashboard's webhook panel writes:

POST https://graph.facebook.com/v26.0/<app-id>/subscriptions
Authorization: Bearer <app-id>|<app-secret>

object=whatsapp_business_account
callback_url=https://your.app/webhook/whatsapp
verify_token=<WA_WEBHOOK_VERIFY_TOKEN>
fields=messages

If your connect flow does this automatically, read the subscriptions first and write only when the object is missing. The address here is app-wide, and one app serves every environment you run. Overwriting it on each connect points production at whichever environment somebody last logged into, and nothing announces the change. Leave an existing object alone. The deployment-specific address belongs per account, in §5.3.

### 5.3 Subscribe the business account (per WABA)

Then subscribe each account, which takes two calls:

POST https://graph.facebook.com/v26.0/<waba-id>/subscribed_apps
Authorization: Bearer <access token>

POST https://graph.facebook.com/v26.0/<waba-id>/subscribed_apps
Authorization: Bearer <access token>

override_callback_uri=https://this-deployment.example.com/webhook/whatsapp
verify_token=<WA_WEBHOOK_VERIFY_TOKEN>

Both answer {"success": true}, and a response without that flag is a failure whatever the HTTP status says.

The order is forced. An override on an account your app has not yet subscribed to comes back with "Before override the current callback uri, your app must be subscribed to receive messages", so the bare call goes first and the override second.

The override is what makes a multi-environment setup work. The app-wide address from §5.2 can name one deployment; pinning the address per account lets staging and production each receive their own traffic. Meta verifies the address with the challenge of §5.1 before accepting the override, so an unreachable host fails the connect instead of producing a channel that receives nothing. That is the failure you want, and the reason the handshake handler never retires.

### 5.4 The field set lives in the dashboard

Here the two Meta messaging platforms part company. Instagram's /me/subscribed_apps takes a subscribed_fields parameter, replaces the whole set on every call, and reads back what it stored. WhatsApp's /<waba-id>/subscribed_apps takes no fields at all. You configure the set once per app under WhatsApp → Configuration → Webhook fields, it applies to every account that app serves, and no API call reads or writes it.

The fields worth ticking for a messaging integration:

FieldWhat arrives
messagesInbound messages, delivery statuses for messages you sent, and system errors. One field, three payloads (§6.3).
account_updateThe account was restricted, banned, disconnected or reconnected (§9).
phone_number_quality_updateThe number's messaging limit changed (§9).
smb_message_echoes, history, smb_app_state_syncCoexistence only (§6.9). Nothing arrives on these for a number onboarded the standard way.

Two consequences of the set being invisible to your code.

A silent channel is ambiguous, and your diagnostics should say so. If your app reports channel health, "subscribed" here can only mean "this app is subscribed to this account". An account with messages unticked is subscribed and silent, and no call distinguishes it from a working one. Put that in the diagnostic instead of a green light you cannot back up.

Somebody can tick a field you never planned for. The dashboard is a web page anyone with access can change, and the next delivery arrives in a shape your parser has never seen. §6.3 is written so that costs you a log line instead of an error loop.

### 5.5 Unsubscribing

DELETE https://graph.facebook.com/v26.0/<waba-id>/subscribed_apps
Authorization: Bearer <access token>

This is per account, so it takes down every number under it (§4.7). Run it when the business disconnects, and leave it alone when a channel is deleted in your app while siblings remain.

Deliveries for an account you no longer serve keep arriving until the unsubscribe lands, so the inbound path needs an answer for "no channel matches this number" that is not an exception (§6.4).

### 5.6 When the business disconnects you

Meta calls two app-level endpoints when somebody removes your app from their Facebook settings. App review requires both, both are public, and both receive a signed_request rather than a signed JSON body:

<base64url signature>.<base64url payload>

The signature is HMAC-SHA256 of the still-encoded payload string, keyed by the app secret. Decode after verifying, never before.

The payload carries a user_id and nothing else you can use. That is why §4.3 stores it: a WhatsApp channel knows its number and its account, and neither appears in this callback. By the time it arrives the token is revoked, so you cannot ask Meta who this person was. A channel connected before you started storing the id is a channel these callbacks can never match.

Deauthorize

Clear the token, clear the expiry, and soft-delete every channel with that user_id. Answer 200 whether or not anything matched.

Data deletion

The same, plus a JSON response Meta shows the user:

{ "url": "https://your.app/data-deletion/status?id=abc123", "confirmation_code": "abc123" }

That URL has to be a real page reporting the state of the request. Three states are enough: never existed, deleted, not deleted yet.

Delete the connection and keep the conversations. The chat history belongs to the business that received it, and the person clicking "remove app" in their personal Facebook settings is one employee of that business. Wiping an organisation's support history because an administrator left is a data-loss incident wearing the costume of a compliance feature.

End-of-section reminder. From here on, every webhook delivery is a POST with an X-Hub-Signature-256 header. That signature is HMAC-SHA256 of the raw body keyed by the App Secret, not by the verify token. Section 6.1 shows the verification.

## 6. Receiving messages

### 6.1 Signature verification, the first thing your handler does

Every inbound POST from Meta carries:

X-Hub-Signature-256: sha256=<hex-digest>

The digest is HMAC-SHA256(app_secret, raw_request_body). To verify:

def verify_signature(raw_body: bytes, header: str | None) -> bool:
    if header is None or not header.startswith("sha256="):
        return False
    provided = header[len("sha256="):]
    expected = hmac_sha256_hex(config.WA_APP_SECRET, raw_body)
    return constant_time_equal(expected, provided)

Three pitfalls to mark with red ink:

  1. The HMAC key is the App Secret, not the verify token. The verify token from §5.1 has no business in this code path. When your signature check fails, check the key first.
  2. HMAC the raw body. Most frameworks parse JSON for you and re-serialise it on access, and re-serialised JSON does not byte-match what arrived. Read the body as bytes before any parser sees it.
  3. Ignore X-Hub-Signature (SHA-1). Meta sends it for backward compatibility. Verify the SHA-256 header and reject a request that lacks it.

A failed check stops the handler and raises an alert. What it answers on the wire is a separate decision, and §6.2 makes it.

### 6.2 Acknowledge fast

Meta wants a 200 within a few seconds. Past that it retries with backoff and eventually disables the subscription, so the handler does the minimum:

def webhook_post(request):
    if not verify_signature(request.raw_body, request.headers.get("X-Hub-Signature-256")):
        log.error("whatsapp webhook signature mismatch")    # alert on this — §10.2
        return Response(status=200)
    inbound_queue.publish(request.raw_body)
    return Response(status=200)

Database writes, media downloads and fan-out to UI sockets happen in a queue consumer.

A non-200 asks for redelivery, and asks for nothing else. The status you return decides whether another copy of these exact bytes would do you any good. For a signature mismatch, an unparseable payload or a bug in your consumer it would not: the identical bytes arrive again and meet the identical code. A wrong App Secret in a deploy then fails every delivery and costs you the subscription, which you get back by hand in the dashboard with the events from that window gone. Answer 200, keep the body where a person can find it, and alert.

Ask for redelivery when you cannot accept the event at all: the queue is unreachable, the process is starting. Returning 200 there acknowledges something you never took responsibility for.

On WhatsApp this decision has teeth, because one delivery can carry several messages. Meta redelivers the whole batch rather than the message that failed. A consumer that throws on the third message of five re-receives all five, and the two it already stored arrive a second time. Your idempotency key (§10.1) makes that survivable, and it is why the loop in §6.6 catches per message.

### 6.3 The payload

{
  "object": "whatsapp_business_account",
  "entry": [
    {
      "id": "1789...",                 // the WABA, not your channel
      "time": 1773347860,
      "changes": [
        {
          "field": "messages",
          "value": {
            "messaging_product": "whatsapp",
            "metadata": {
              "display_phone_number": "+1 555 0100",
              "phone_number_id": "1270..."        // the channel
            },
            "contacts": [
              { "profile": { "name": "Jane" }, "wa_id": "15551234567" }
            ],
            "messages": [
              {
                "from": "15551234567",
                "id": "wamid.HBgLMT...",
                "timestamp": "1712948120",
                "type": "text",
                "text": { "body": "Hi, do you ship to Berlin?" }
              }
            ]
          }
        }
      ]
    }
  ]
}

The whatsapp_business_account object has one shape, and this is it: entry[].changes[] carrying a {field, value} pair. No second delivery form waits to be discovered later. Instagram has two, so a parser ported from there arrives expecting the wrong one.

Four properties of this payload decide how the parser is written.

entry[].id is the business account, and routing on it is wrong. The channel is value.metadata.phone_number_id, several layers down. One account with three numbers produces three channels and one entry[].id, so reading the entry id as the account identity, which is correct on Instagram, collapses three conversations into whichever channel you looked up first.

timestamp is a string of whole seconds, not an integer and not milliseconds. Declare the field as an integer and the first batch Meta sends fails, which is every batch. Nothing should order on it anyway: sort by the id you assign on write, not by a clock you do not control.

Which arrays are present identifies the event, not field. value may carry messages, statuses, errors, or one of the Coexistence arrays of §6.9. Branch on presence and log the key names of anything you did not recognise:

def handle_change(change):
    value = change["value"]
    channel = find_channel(value["metadata"]["phone_number_id"])    # §6.4
    handled = False
    for key, handler in (("messages", on_messages), ("statuses", on_statuses),
                         ("message_echoes", on_echoes), ("errors", on_errors)):
        if key in value:
            handler(channel, value[key])
            handled = True
    if not handled:
        log.warn("uninterpretable whatsapp change",
                 field=change.get("field"), keys=list(value.keys()))

Gating on the name instead is tempting and goes wrong in both directions. The subscribed set lives in the App Dashboard where your code cannot read it (§5.4), so somebody can tick a field tomorrow and start delivering a shape you have never seen. A whitelist drops those; a blacklist invents an inbox entry out of an account notification. Raising an exception on an unknown field is worse than either: a 500 makes Meta redeliver that batch on a schedule, forever, and your log fills with one entry per retry.

contacts[] is a sibling array, not a property of the message. It carries the display name, correlated to the message by wa_id matching messages[].from. It can be absent, and it is absent when you would most want it. Never dereference it positionally.

### 6.4 Routing to a channel

def find_channel(phone_number_id):
    return channel_repo.find_live_by_phone_number_id(phone_number_id)

Never index payload["entry"][0]["changes"][0]. Meta guarantees nothing about how it batches, and a hardcoded index drops everything after the first with a 200 going back on the wire.

A lookup that finds nothing is an ordinary outcome. It means the account is subscribed and the channel is gone: the business disconnected in your app and the unsubscribe has not landed, or never ran (§5.5). Log it and return.

Do not filter out channels with an expired token here. Receiving costs no token; the token is for media downloads and replies, and both fail on their own terms with a message that says so. Filtering makes the customer's message vanish before anyone writes it down, while a 200 tells Meta it was handled.

### 6.5 Mapping sender → client

The customer's identity is their wa_id, and that is the whole of it:

def upsert_client(channel, wa_id, profile_name):
    existing = client_external_repo.find(channel.id, wa_id)
    if existing:
        return existing.client
    client = client_repo.create(name=profile_name or format_phone(wa_id))
    client_external_repo.create(channel.id, wa_id, client_id=client.id)
    return client

No profile endpoint enriches this. WhatsApp has no username, no avatar and no lookup by number. contacts[].profile.name is whatever the customer typed into their own WhatsApp profile, and it changes whenever they change it. Take it as a default for the display name and let the operator override it, because a support queue full of people called "😎" is a real outcome.

Two deliveries from a new customer can land on two workers at once, miss the lookup and both insert. Put a unique index on (channel_id, wa_id) and let the insert decide: on conflict, read the row the other worker wrote. The find stays as the fast path, and the index is the correctness.

Reply to the wa_id exactly as received. It looks like a phone number and it is not always the number the customer would give you. Several countries add or drop a digit between the dialable form and the WhatsApp form, Argentina's 9 and Mexico's former 1 being the known cases, and the send endpoint answers 131026, "unable to deliver", for a number one normalisation away from correct. Store the string Meta sent, send it back unchanged, and keep any prettified version in a separate column for display.

A customer who changes their number arrives as a message of type system naming the old and the new wa_id. Move the external id onto the existing client instead of opening a second conversation with the same person.

### 6.6 Persisting and dispatching

def on_messages(channel, messages):
    for index, msg in enumerate(messages):
        try:
            client = upsert_client(channel, msg["from"], profile_name_for(msg["from"]))
            chat = chat_repo.get_or_create(channel, client)
            persist_inbound(chat, msg)
        except Exception as e:
            report(e, channel_id=channel.id, wamid=msg.get("id"),
                   type=msg.get("type"), index=index, total=len(messages))
            continue                                   # the siblings are not at fault

The loop catches per message and carries on. Letting one malformed message abort the batch loses the messages after it and duplicates the ones before it on the retry.

Report the failures instead of swallowing them. A bare continue is how a message type you do not handle stays invisible for a year. Log the wamid, the type and the position in the batch, and send it somewhere a person looks.

Persisting writes the message with the wamid as its external id (§10.1), then publishes to whatever moves it to the operator's screen. Media downloads go on their own queue (§6.7) so a slow file never delays the text.

### 6.7 Media

An inbound media message carries an id, not a URL:

{
  "from": "15551234567",
  "id": "wamid.HBgLMT...",
  "timestamp": "1712948120",
  "type": "image",
  "image": {
    "id": "1023...",
    "mime_type": "image/jpeg",
    "sha256": "9f86d0...",
    "caption": "is this the right part?"
  }
}

Getting the bytes takes two requests. Resolve the id to a URL:

GET https://graph.facebook.com/v26.0/<media-id>?phone_number_id=<phone-number-id>
Authorization: Bearer <access token>
{ "url": "https://lookaside.fbsbx.com/whatsapp_business/...",
  "mime_type": "image/jpeg", "sha256": "9f86d0...", "file_size": 284413, "id": "1023..." }

Then fetch that URL with the same Authorization header. It looks like a plain CDN link and behaves like an authenticated endpoint: without the header it answers an error, and an HTTP client that drops headers on redirect hands you that error instead of the file.

The URL expires five minutes after it is issued. That one fact decides the design: resolve the id inside the download job, not in the webhook consumer. Resolving at receive time and queuing the URL produces a job that works in development, where the queue is empty, and fails in production the first time the queue is five minutes deep.

Media ids expire too, so the download cannot wait for a quiet Sunday. Pull the file into your own storage and serve it from there.

TypeFormatsCeiling
imageJPEG, PNG5 MB
audioAAC, AMR, MP3, MP4 audio, OGG16 MB
videoMP4, 3GPP16 MB
documentPDF, office formats, plain text100 MB
stickerWebP100 KB static, 500 KB animated

Two details in the payload are worth keeping. sha256 lets you verify what you downloaded and recognise a file you already have. A voice note arrives as audio with voice: true, and the operator wants it rendered as a voice message rather than an attached file; that flag is the only thing distinguishing the two.

The row starts in a pending state, with no file behind it and no dimensions or duration yet. Make those columns nullable instead of writing zeros: a zero width reads as "broken" to anything downstream that validates media, and a renderer that skips malformed attachments skips these too.

A failed download must not lose the message. The text and the caption are already on the operator's screen; the attachment row moves to a failed state and says why, and a retry stays possible for as long as Meta keeps the id.

### 6.8 Message types you cannot render

type is an open set. Alongside the five media types and text, a customer can send:

TypeWhat it is
locationCoordinates, optionally with a name and address.
contactsOne or more shared contact cards.
reactionAn emoji attached to an earlier message, naming it by message_id. An empty emoji means the reaction was removed.
button, interactiveA tap on a button or list you sent in a template.
orderA cart submitted from a business catalogue.
systemAn account event rather than a message: a number change, for one (§6.5).
unsupportedMeta could not deliver the content. The errors[] array on the message says why.

You will not render all of these on day one, and how you decline matters more than the list does.

Never create an empty message. A customer who sends their location and sees nothing happen concludes the number is dead. Store a placeholder naming the type. The type string serves as that placeholder, and it handles whatever Meta invents next year on the day it appears.

Never let an unhandled type raise. An exception here is a 500, a redelivery of the whole batch, and duplicates of every sibling message. Skip the message, and do not throw.

A reply carries a context object naming the message it answers, and a forwarded message says so in the same place. Both are cheap to store and expensive to add later, because they are properties of messages you have already written down.

There is no edit event and no delete event. A customer who deletes a message on their phone deletes it on their phone, and your copy remains the record of what was said. Coexistence is the exception: a deletion made in the business's own app arrives as an echo of type revoke.

### 6.9 Coexistence: echoes and history

Coexistence is the onboarding flow that leaves a number in the owner's WhatsApp Business app while your integration works alongside it. The owner keeps answering from their phone, your operators answer from your app, and both see the same conversations. For a business whose owner is already on WhatsApp all day, this is the only onboarding they will accept. You choose it in §4.1 by setting featureType to whatsapp_business_app_onboarding.

It adds three webhook fields.

smb_message_echoes delivers a copy of every message the owner sends from the app, in its own array:

"value": {
  "metadata": { "display_phone_number": "+1 555 0100", "phone_number_id": "1270..." },
  "message_echoes": [
    { "from": "+1 555 0100", "to": "15551234567", "id": "wamid...",
      "timestamp": "1712948300", "type": "text", "text": { "body": "On its way!" } }
  ]
}

An echo is never an element of messages[]. The array it arrived in is what makes it an echo, and that is the rule to write the branch on. Comparing from to the business number fails on formatting alone, since the echo's from is the display form and a wa_id is bare digits. Worse, an accept rule listing only messages and statuses discards the whole delivery before the echo branch can run, leaving that branch unreachable while a test built from a synthetic payload still passes.

Echoes do not cover messages sent through the Cloud API, so an echo can never duplicate a reply your own operator sent. Instagram does the opposite: its echo arrives carrying the id you already adopted, and a unique index has to absorb it. Here every echo is news, and the operator looking at the thread needs to see it before they answer the customer a second time.

history delivers the conversations that existed before onboarding, in batches, once. It decides whether an operator inherits a live conversation or a blank screen next to a customer who thinks they are mid-conversation. The business can decline to share it, in which case one history webhook arrives carrying error 2593109 instead of messages. Handle that as a normal outcome and tell the operator the thread starts here.

smb_app_state_sync delivers the contacts in the owner's phone and keeps sending updates as they change. Useful for naming clients, and optional.

Nothing arrives on these fields for a number onboarded the standard way, so handling them costs nothing on the numbers that do not use them.

### 6.10 Delivery statuses

Read this section even if you skip the rest. A 200 from the send endpoint means Meta accepted the request. It says nothing about delivery, and the only report of non-delivery arrives here. An integration that does not parse statuses[] has no idea whether any message it ever sent reached anybody.

"statuses": [
  {
    "id": "wamid.HBgLMT...",          // the id your send adopted
    "status": "delivered",
    "timestamp": "1712948160",
    "recipient_id": "15551234567",
    "conversation": { "id": "...", "origin": { "type": "service" } },
    "pricing": { "billable": true, "category": "service" }
  }
]

Statuses arrive on the messages field, the same one inbound messages use. Nothing extra to subscribe to: if messages reach you, statuses already do too, and something upstream is discarding them.

Four values: sent, delivered, read, failed. Treat the set as open and log anything else rather than guessing, because a tick that means nothing is worse than no tick.

The ladder only goes up. A customer with the thread open can read a reply before its delivered status is written, and the two events arrive close enough together that ordering is not guaranteed. Guard every transition on the state it is allowed to leave, so a late delivered cannot pull a message back out of read.

A failed status carries an errors[] array with code, title and message. These are the same codes the send endpoint returns (§7.6), and the interesting ones show up only here, because the send already answered 200. Resolve them to a sentence an operator can act on and attach it to the message. "Not delivered" on its own tells them to retype the reply and send it again, which is the wrong move for most of the list.

pricing and conversation describe what Meta charged for. Store them: they are the raw material for answering "why is the WhatsApp bill this size" later.

### 6.11 Read receipts and typing

Both go to the send endpoint, and both name a message you received:

{
  "messaging_product": "whatsapp",
  "status": "read",
  "message_id": "wamid.HBgLMT...",
  "typing_indicator": { "type": "text" }
}

Omit typing_indicator to mark as read alone. Marking one message read marks the earlier messages in the conversation read as well, so name the last message the operator saw and one call covers the thread.

Send it when an operator opens the chat, not when the message arrives. Two blue ticks mean a person is looking at this. A receipt sent on ingest tells the customer they have been read the instant they press send, including at three in the morning when the queue is empty and no one is reading anything.

The typing indicator clears when you reply or after 25 seconds, whichever comes first. Show it while an operator is typing. A permanent typing bubble in front of an unattended queue is a worse signal than silence.

## 7. Sending messages

### 7.1 Endpoint

POST https://graph.facebook.com/v26.0/<phone-number-id>/messages
Authorization: Bearer <access token>
Content-Type: application/json

Every body starts with the same three keys, whatever the type:

{
  "messaging_product": "whatsapp",
  "recipient_type": "individual",
  "to": "15551234567"
}

messaging_product is mandatory on every call to this endpoint, read receipts and typing indicators included. Omitting it comes back as code 100, the generic "invalid parameter" that also covers a dozen unrelated mistakes, so the response will not tell you which one you hit.

### 7.2 Body — text

{
  "messaging_product": "whatsapp",
  "recipient_type": "individual",
  "to": "15551234567",
  "type": "text",
  "text": { "body": "Yes, we ship to Berlin.", "preview_url": false }
}

The body holds up to 4096 characters. The budget is characters, so a Cyrillic or emoji reply costs the same as an English one. Instagram counts bytes, which is a good reason not to share a validator between the two.

Check the length before the message reaches your database. The operator can shorten a reply they are still looking at, and can do nothing about one already sitting in the thread marked as failed.

preview_url makes WhatsApp render a link card for the first URL in the body. It costs a fetch on Meta's side and produces no card at all for anything Meta cannot reach, with nothing in the response to say so. Treat it as a display preference rather than a guarantee.

### 7.3 Body — media

One media object per message, named by the type:

{
  "messaging_product": "whatsapp",
  "recipient_type": "individual",
  "to": "15551234567",
  "type": "document",
  "document": {
    "link": "https://your.app/files/invoice-4471.pdf",
    "caption": "Invoice 4471",
    "filename": "invoice-4471.pdf"
  }
}

Text and media cannot travel as one message. A reply with both becomes a caption, and captions exist on image, video and document but not on audio or sticker. filename belongs to document alone, and without it the customer sees your URL's last path segment as the file name.

Two ways to hand Meta the bytes, and both are a fetch Meta performs.

link has Meta fetch the URL during the send. The address has to be reachable from Meta's servers and stay up long enough for the fetch. Nothing behind a session, a signed URL that expires in seconds, or a private network will work.

id has you upload the file once to POST /<phone-number-id>/media, take the id back, and send {"id": "..."} in place of {"link": "..."}. Meta holds the file for 30 days. Make this the default: it works for private storage, it costs one upload instead of a fetch per recipient for anything you send repeatedly, and it fails at upload time where you can see it rather than at send time where the customer does.

The link route is where local development breaks. Meta fetches the URL from its own servers, so an address that resolves on your machine resolves to nothing on theirs. A tunnel makes your webhook work and leaves your media links broken unless your app builds them from the same address. The send is accepted, a wamid comes back, and the failure arrives minutes later as a failed status.

### 7.4 Response

{
  "messaging_product": "whatsapp",
  "contacts": [ { "input": "15551234567", "wa_id": "15551234567" } ],
  "messages": [ { "id": "wamid.HBgLMT...", "message_status": "accepted" } ]
}

message_status: "accepted" says out loud what §6.10 insists on: the request was taken, the message is not delivered.

Adopt messages[0].id as the message's external id. Every status webhook names it, and a reply with no wamid stored is a reply whose delivery you can never report. contacts[0].wa_id can differ from the input you sent; store what came back.

### 7.5 The async pipeline

The operator's browser must not wait on Graph. Write the message first, send it second:

def send_reply(chat, operator, text):
    message = message_repo.create(chat, operator, text, status="pending")   # visible immediately
    outbound_queue.publish(message.id)
    return message

def outbound_consumer(message_id):
    message = message_repo.get(message_id)
    channel = message.chat.channel
    wamid = wa_api.send_text(channel.phone_number_id, channel.access_token,
                             to=message.chat.client.wa_id, text=message.text)
    message_repo.adopt_external_id(message, wamid, status="sent")

The row exists before the send, so the message has a local id the UI can render and the operator sees their own reply the moment they press send. The wamid is adopted afterwards.

A reply the provider refuses keeps its local id forever, because there is no provider id to adopt. Mark it failed once the retries are done rather than on the first error, or a message that succeeds on the second attempt spends the minute in between telling the operator it never arrived. Give the mark a reason resolved from the error code of §7.6, not from Meta's message string.

### 7.6 Error handling

Graph reports failures as a 4xx with the detail in the body:

{ "error": { "message": "...", "type": "OAuthException", "code": 190,
             "error_subcode": 463, "fbtrace_id": "A1b2C3..." } }

Read the body even when the status says the request failed. An HTTP client configured to raise on 4xx before you reach the body throws away the only part of the response that says what to do next, and in many clients the exception it raises contains the request URL, which is how a token reaches a log (§10.4).

Retriability is a property of the code, not of the status. Meta answers 400 for throttling as readily as for a malformed body, so a handler branching on 429 finds nothing to branch on.

CodeMeaningRetry?
130429Throughput limit for the number reachedYes, with backoff
131056Too many messages to this one recipient too quicklyYes, with backoff
80007The business account hit a rate limitYes, with backoff
131016, 131057Service unavailable, or the account is in maintenanceYes
131000Unknown failure on Meta's sideYes, a bounded number of times
131047The 24-hour window has closed (§7.7)No
131026Undeliverable: not a WhatsApp user, or cannot receiveNo
131051Message type not supportedNo
131052, 131053Media could not be downloaded from you, or uploaded to the recipientNo, fix the file or the URL
133010The number is not registered (§4.5)No
131031, 368The number or the account is restricted (§9)No
131042The business has no working payment methodNo
190, 0, 10The token is dead or the permission is gone (§8)No, reconnect
100You sent something malformedNo, it is your bug

Three of these say nothing about the message you sent. 190 means the channel is dead and every reply queued behind it will fail the same way. 133010 and 131031 mean the number is out of action. Retrying any of them burns the retry budget and delays the moment somebody hears the channel needs attention, so escalate them to the channel instead of burying them on one message.

The same codes arrive by two routes, the send response and the failed status of §6.10, so classify in one place and call it from both.

### 7.7 The 24-hour service window

The window opens when the customer sends a message and runs 24 hours from it. Inside it you can send anything; outside it every free-form send comes back as 131047. Each new inbound message resets the clock, so an active conversation never closes and a thread that goes quiet overnight is closed by morning.

The window is per customer per number. Two numbers of the same business hold two independent windows with the same person.

Only a template reopens a closed window, which is the boundary this article stops at (§1). Without templates a closed thread stays closed until the customer writes again, and that is the behaviour to build the UI around.

Show the operator the clock. They decide whether to answer now or in the morning, and they cannot weigh that against a deadline they cannot see. Put the remaining time on the thread, and when it runs out, disable the composer with the reason written next to it. A composer that accepts a reply, holds it a few seconds and then marks it failed trains operators to retype and resend, and the resend fails too.

## 8. Tokens

### 8.1 There is no refresh endpoint

Instagram gives you a long-lived token and a call to extend it, and the Instagram article spends a section on the cron that runs it. WhatsApp has no such call. Whatever token Embedded Signup hands you is what you have until the business goes through the dialog again.

Which token that is depends on how far your app has got through Meta's verification, and the difference separates an integration that runs itself from one that needs a human every eight weeks.

### 8.2 The system user token, how this should work

A Login Configuration can be set to issue a system user access token. It belongs to your business rather than to the person who clicked through the dialog, it carries the same asset scope, and it does not expire. An option gives it a 60-day life; leave that off.

This requires Advanced access to whatsapp_business_management, which requires app review and Business Verification, the gate from §1.

Everything in §4 works unchanged: the same code exchange, the same debug_token call, the same granular_scopes traversal. Two things differ, both in the response. There is no expires_in, so access_token_expires_at is NULL and means "no deadline". And type in the introspection reads SYSTEM_USER rather than USER, which is the cheapest confirmation that the configuration does what you set it to do. Check it once on a real connect instead of trusting the dashboard.

Channels connected under the old configuration do not migrate themselves. Each number goes through the dialog once more to pick up a token of the new kind.

### 8.3 The 60-day user token, what you get before that

Until then, Embedded Signup issues a user access token that expires in about 60 days. It works for everything in this article, and it is a fine way to get the integration built and demonstrated.

It cannot be extended. The Facebook token exchange is the call everyone tries:

GET /v26.0/oauth/access_token?grant_type=fb_exchange_token&fb_exchange_token=<token>

It answers with a new token string, a new issued_at, and the same expiry. The expires_in it reports is the remainder of the original 60 days rather than a fresh 60. The deadline did not move, and a refresher cron built on it appears to work for weeks before the first channel dies on schedule.

So there is no refresher to write. What replaces it:

Do not filter expired channels out of your lookups. It reads like hygiene and it breaks four things. A login has to find the expired channel or it creates a duplicate instead of reconnecting the original. A sibling check before unsubscribing (§4.7) has to count the expired number or it unsubscribes a whole account. A deauthorize callback has to find channels with any token at all, or an expired channel can never be deauthorized. And the inbound path needs no token whatsoever (§6.4). Keep the date, show the date, and let the calls that need a token fail on their own terms.

### 8.4 The second deadline

debug_token returns data_access_expires_at alongside expires_at, and it is a second deadline rather than a longer version of the first. Meta separates authentication from data access: data access runs 90 days from the user's last activity, and when it lapses the token still authenticates while the data it could reach is gone.

It sits behind the token deadline and rarely kills a channel first. Store it anyway: a channel that fails with an unexpired expires_at is otherwise a confusing hour, and this field explains it.

## 9. The health of the number

A WhatsApp number is more than an endpoint. It has a reputation Meta scores, a messaging limit derived from that score, and a status that can go from working to restricted without anything in your code changing. None of this has an Instagram equivalent, and all of it reaches you as webhooks on the fields from §5.4.

Quality and limits

quality_rating comes back with every number in §4.4 as GREEN, YELLOW or RED. Meta computes it from what recipients do, and blocks and "report" taps push it down. A number that stays flagged loses a tier of its messaging limit.

phone_number_quality_update announces the limit changing:

{ "field": "phone_number_quality_update",
  "value": { "display_phone_number": "+1 555 0100",
             "event": "THROUGHPUT_UPGRADE",
             "old_limit": "TIER_2K", "current_limit": "TIER_10K" } }

The tiers run TIER_50 through TIER_100K to TIER_UNLIMITED, plus TIER_NOT_SET for a number that has never sent anything. They cap business-initiated conversations, so an inbox that answers customers will not run into them. Store the value anyway: it is the readable form of the reputation that governs the restrictions below.

Restrictions and bans

account_update carries an event naming what happened to the business account. The ones that change what your app should say:

EventWhat it means
ACCOUNT_RESTRICTIONLimited for a policy violation. restriction_info names the restriction and when it lifts.
ACCOUNT_VIOLATIONA violation was recorded. violation_info names the type.
DISABLED_UPDATEThe account was banned. ban_info carries the state and the date.
ACCOUNT_DELETEDThe business account is gone.
ACCOUNT_OFFBOARDED / ACCOUNT_RECONNECTEDThe number was re-registered somewhere else, or came back. This is the event behind a channel that starts answering 133010 for no visible reason.
PARTNER_APP_UNINSTALLED / PARTNER_REMOVEDThe business disconnected your app from their side (§5.6).

The rest of the event list covers pricing tiers, ad account linking and partner certification, which a messaging integration can log and ignore. Log the whole event either way: the list grows, and the event you did not know about is the one you will want in the timeline afterwards.

What to do with it

Distinguish three channel states in your UI, because they have three remedies and one of them is a button in your app.

## 10. Operational checklist

### 10.1 Idempotency

### 10.2 Observability

Alert on signature mismatches, 190 and its neighbours per channel, failed statuses as a rate rather than one at a time, media downloads exhausting their retries, and any account_update from the table in §9.

Log the phone_number_id and the wamid on every line. Both are short, neither is a secret, and a support question about one message is answerable by grep with them and unanswerable without.

Log the payload of anything you could not interpret, including the key names from the fallback in §6.3. That log line is how you find out a field got ticked in the dashboard.

### 10.3 Rate limits

Three ceilings with three codes (§7.6): throughput on the number, volume on the business account, and a per-recipient limit that catches a loop answering the same person. Back off per channel rather than globally, so one busy number does not stall the others.

### 10.4 Secret hygiene

### 10.5 Local development

A tunnel gets webhooks to your machine and leaves media links broken unless your app builds them from the tunnel address (§7.3). Use the id route while developing and the problem does not arise.

Meta's test number is free and rehearses nothing: it messages a handful of pre-registered recipients, has no quality rating, and your own connect flow cannot register it. Treat it as a way to see a payload rather than a staging environment.

### 10.6 Retries

Exponential backoff, a bounded number of attempts, a dead-letter queue for what is left. Classify first (§7.6): retrying a code that will never succeed spends the budget a transient failure needed and delays the moment the operator hears about it.

## 11. Glossary

WABA (WhatsApp Business Account)
The container for a business's phone numbers, templates and settings. Webhook subscriptions live here, one subscription covering every number underneath.
wamid
The id of a message, unique across the platform. Meta assigns it in both directions, and every delivery status names it.
Embedded Signup
The browser dialog that walks a business through connecting a number and grants your app permissions on it. Returns an authorization code (§4.1).
Coexistence
The onboarding flow that leaves a number working in the owner's WhatsApp Business app while your integration works alongside it. Adds echo, history and contact-sync webhooks (§6.9).
Service window
The 24 hours after a customer's last message, during which you can send them anything. Outside it, only an approved template (§7.7).
System user token
A token belonging to your business rather than to a person, issued by a Login Configuration set up for it, and free of the 60-day expiry (§8.2).
Echo
A copy of a message the business sent from its own WhatsApp Business app, delivered in message_echoes[] under Coexistence. Never a copy of something you sent through the API.

## A note on what this article is

This is a behaviour spec: how to build the thing, and not a description of any one codebase. The wire facts, every endpoint and header and parameter and field name and error code, are what flows today against Cloud API v26.0, taken from live traffic where live traffic was available and from Meta's reference where it was not. None of this should be taken as a WhatsApp API specification; Meta evolves these endpoints continuously, and the parts of this article covering reputation, limits and review are the parts most likely to have moved. When something stops behaving as described, the source of truth is your traffic capture rather than this document.