WhatsApp API & Developers

Receive WhatsApp Messages by API: Inbound Webhooks

Sending is a request you control. Receiving is the opposite: customers write whenever they like, WhatsApp pushes each message to you, and if your endpoint is down or your parser trips on an unfamiliar message type, that message is effectively gone. Meta offers no endpoint to fetch past messages later.

So the job of a system that can receive WhatsApp messages by API is narrow but unforgiving. Accept every inbound event, store it once, work out who sent it, and reply while the conversation is still open.

This guide walks through the Cloud API's inbound payload field by field, the message types you'll actually see, the new rule that phone numbers may be missing, a working Python receiver, media downloads, and how Tenashi's message.received event compares. It sits under our complete guide to the WhatsApp API.

How an inbound message reaches your server

There is no inbox to poll. When someone messages your business number, Meta sends an HTTPS POST to your callback URL under the messages webhook field. Three things have to be in place before the first message arrives:

  1. A verified endpoint. Your URL must answer Meta's verification GET with the challenge value and present a valid TLS certificate (self-signed certificates are rejected).
  2. The messages field subscribed in App Dashboard, WhatsApp, Configuration. Receiving it needs the whatsapp_business_messaging permission.
  3. Your app subscribed to the WhatsApp Business Account that owns the number. If you onboarded the account through the API or Embedded Signup, that's a POST /WABA_ID/subscribed_apps call.

Our WhatsApp API webhooks guide covers the handshake, retries and overrides in depth. The one rule to take from it here is Meta's own: there are no APIs for fetching historical webhook data, so capture and store payloads as they arrive.

Walk through one inbound payload

Here is a text message as it arrives in October 2026, including the business-scoped user ID fields Meta began adding in April:

{
  "object": "whatsapp_business_account",
  "entry": [{
    "id": "102290129340398",
    "changes": [{
      "field": "messages",
      "value": {
        "messaging_product": "whatsapp",
        "metadata": { "display_phone_number": "15550783881", "phone_number_id": "106540352242922" },
        "contacts": [{
          "profile": { "name": "Sheena Nelson" },
          "wa_id": "16505551234",
          "user_id": "US.13491208655302741918"
        }],
        "messages": [{
          "from": "16505551234",
          "from_user_id": "US.13491208655302741918",
          "id": "wamid.HBgLMTY1MDM4Nzk0MzkVAgASGBQzQTRBNjU5OUFFRTAzODEwMTQ0RgA=",
          "timestamp": "1793266860",
          "type": "text",
          "text": { "body": "Does it come in another color?" }
        }]
      }
    }]
  }]
}

What each part is for:

FieldWhat it tells youWhat to do with it
entry[].idThe WhatsApp Business Account IDUseful if one app serves several accounts
metadata.phone_number_idWhich of your numbers received itRoute to the right team, and reply from the same number
contacts[].profile.nameThe name on their WhatsApp profileDisplay only; people change it
contacts[].user_id, messages[].from_user_idThe business-scoped user ID (BSUID)Your stable key for the person
messages[].from, contacts[].wa_idTheir phone number, when Meta shares itStore it, but don't require it
messages[].idThe message's wamidYour de-duplication key and the ID you quote when replying
messages[].timestampUnix seconds, as a stringConvert and store; don't trust arrival order
messages[].typeWhich object holds the contentBranch on it

Two structural traps. First, outbound delivery updates arrive on the same messages field, but in a statuses array instead of a messages array, so check which one is present. Second, Meta can batch up to 1,000 updates in one request, so loop over every entry and every change rather than reading entry[0].

The message types you'll actually see

Text is the easy case. A production receiver needs a branch for each of these, plus a fallback that stores anything unknown instead of throwing:

typeWhere the useful content isWorth knowing
texttext.bodycontext.forwarded or frequently_forwarded flags forwarded messages
image, video, documentid, mime_type, sha256, optional captionDocuments also carry filename
audioid, mime_type, voicevoice is true for recorded voice notes
stickerid, mime_typeWebP
interactivebutton_reply.id or list_reply.id, plus titleThe id is the one you set when you built the button
buttonbutton.payload, button.textA quick-reply tap on a template you sent
reactionreaction.message_id, reaction.emojiNo emoji means the user removed their reaction
locationlatitude, longitude, optional name, address
systemsystem.typeuser_changed_number or user_changed_user_id; no contacts array
unsupportederrorsA type Cloud API can't deliver to you

Two optional objects matter for business logic. A context with an id means the customer replied to a specific message of yours, which is how you match "Yes" to the right appointment. A referral object means the chat started from a Click to WhatsApp ad, and carries the ad ID and a click ID (ctwa_clid) for attribution.

Who sent it: phone numbers are now optional

For years, from was the customer's phone number and every CRM keyed contacts on it. That assumption no longer holds. Meta has been adding a business-scoped user ID to every messages webhook since early April 2026, as user_id and from_user_id, in a format like US.13491208655302741918.

When a user adopts a WhatsApp username, their phone number is left out of webhooks unless your business number messaged or called them, or heard from them, within the last 30 days, or they're in your contact book. The 30-day lookback is per business number, so a second number in the same portfolio may see the BSUID with no phone.

What that means for your schema:

  • Make the BSUID the primary identity for a contact, and the phone number an optional, unique-when-present attribute.
  • Handle system messages of type user_changed_user_id: the payload carries previous_user_id, which links the old identity to the new one.
  • Never write code that indexes into msg["from"] without a fallback.

Our guide to BSUIDs and usernames covers the send side, where the recipient field accepts a BSUID.

A minimal receiver in Python

This Flask app checks the signature against the raw request bytes, walks every entry and change, ignores status updates, and hands each inbound message to a storage function. It runs as written; swap save_inbound for a database insert.

import hashlib, hmac, json, os
from flask import Flask, request, abort

app = Flask(__name__)
APP_SECRET = os.environ["APP_SECRET"].encode()

def signature_ok(raw: bytes, header: str) -> bool:
    expected = "sha256=" + hmac.new(APP_SECRET, raw, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected.encode(), (header or "").encode())

@app.post("/webhooks/whatsapp")
def inbound():
    raw = request.get_data()  # exact bytes, read before any JSON parsing
    if not signature_ok(raw, request.headers.get("X-Hub-Signature-256")):
        abort(401)
    payload = json.loads(raw)
    for entry in payload.get("entry", []):
        for change in entry.get("changes", []):
            if change.get("field") != "messages":
                continue
            value = change["value"]
            names = {}
            for c in value.get("contacts", []):
                for key in (c.get("user_id"), c.get("wa_id")):
                    if key:
                        names[key] = c.get("profile", {}).get("name")
            for msg in value.get("messages", []):  # inbound only; "statuses" is outbound
                save_inbound(
                    wamid=msg["id"],
                    phone_number_id=value["metadata"]["phone_number_id"],
                    sender=msg.get("from_user_id") or msg.get("from"),
                    phone=msg.get("from"),  # may be absent for username users
                    name=names.get(msg.get("from_user_id")) or names.get(msg.get("from")),
                    kind=msg["type"],
                    text=extract_text(msg),
                    sent_at=int(msg["timestamp"]),
                    raw=msg,
                )
    return "", 200

def extract_text(msg: dict):
    t = msg["type"]
    if t == "text":
        return msg["text"]["body"]
    if t in ("image", "video", "document"):
        return msg[t].get("caption")
    if t == "interactive":
        reply = msg["interactive"]
        return reply.get(reply["type"], {}).get("title")
    if t == "button":
        return msg["button"]["text"]
    if t == "reaction":
        return msg["reaction"].get("emoji")  # missing emoji = reaction removed
    return None

def save_inbound(**row):
    ...  # INSERT ... ON CONFLICT (wamid) DO NOTHING

Keep the handler fast. Meta's throughput guidance asks for a median webhook response under 250 ms, so if a message triggers an AI call or a CRM sync, enqueue it and return 200 first. Why the signature must be computed on raw bytes, and what breaks when it isn't, is in verifying WhatsApp webhook signatures.

Download media and reply

Media messages carry a media ID, and Meta has been adding a direct url to the webhook object since November 2025 (rolling out gradually). Either way, three rules from Meta's media docs apply: the URL expires after 5 minutes, media IDs that arrive in webhooks expire after 7 days, and the download needs your access token in the Authorization header.

import os, requests

GRAPH = "https://graph.facebook.com/v23.0"
AUTH = {"Authorization": f"Bearer {os.environ['WHATSAPP_TOKEN']}"}

def fresh_url(media_id: str) -> str:
    r = requests.get(f"{GRAPH}/{media_id}", headers=AUTH, timeout=10)
    r.raise_for_status()
    return r.json()["url"]

def download_media(media: dict, path: str) -> str:
    """media is the image, audio, video or document object from the webhook."""
    url = media.get("url") or fresh_url(media["id"])
    r = requests.get(url, headers=AUTH, timeout=60)
    if r.status_code == 404:  # media URLs expire after 5 minutes
        r = requests.get(fresh_url(media["id"]), headers=AUTH, timeout=60)
    r.raise_for_status()
    with open(path, "wb") as f:
        f.write(r.content)
    return r.headers.get("Content-Type", media.get("mime_type", ""))

Run downloads in a worker, not inside the webhook handler, and copy files to your own storage. Customers can send files up to 100 MB; anything bigger arrives as error 131052. Sending files back is covered in sending media with the WhatsApp API.

To reply well, first mark the message as read and show a typing indicator. Meta dismisses the indicator when you reply or after 25 seconds:

curl "https://graph.facebook.com/v23.0/PHONE_NUMBER_ID/messages" \
  -H "Authorization: Bearer $WHATSAPP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "messaging_product": "whatsapp",
    "status": "read",
    "message_id": "wamid.HBgLMTY1MDM4Nzk0MzkVAgASGBQzQTRBNjU5OUFFRTAzODEwMTQ0RgA=",
    "typing_indicator": { "type": "text" }
  }'

Then send your reply from the same phone_number_id, with context.message_id set to their wamid if you want it quoted. The inbound message opened a 24-hour window, so no template is needed. The examples use Graph API v23.0; use the current version from Meta's changelog.

Receiving through Tenashi: one event for DMs and groups

Tenashi's webhooks fire message.received when someone messages one of your linked numbers, in a DM or in a group your number belongs to. The body carries the message in the same shape the REST API returns:

{
  "id": "evt_5f0c8e2a91b34d7c6e1f2a90",
  "type": "message.received",
  "data": {
    "message": {
      "id": "cmh1r7t8y9u0i1o2p3a4s5d6",
      "conversationId": "cmh0a9s8d7f6g5h4j3k2l1",
      "direction": "in",
      "type": "text",
      "text": "Can I move my class to Thursday?",
      "status": "received",
      "sender": { "phone": "+919811122233", "name": "Priya Nair" },
      "chat": { "type": "dm", "name": "Priya Nair", "phone": "+919811122233" }
    }
  }
}

Check chat.type to tell a DM from a group, de-duplicate on the event id (delivery is at least once), and reply with POST /api/v1/messages using the conversationId and, to quote, replyTo. Messages imported from your chat history when you link a number don't fire events, so your handler only sees new traffic.

Where Tenashi fits

Most teams wiring up inbound WhatsApp don't actually want to build a receiver; they want every customer message to land somewhere a person will answer it. Tenashi does that on the number you already use, linked by QR scan. DMs and groups flow into one shared inbox with a CRM under every chat, and the same events go to your webhook if you also want them in your own systems. The API is included on every paid plan, from $18/mo.

The limit to know: Tenashi runs on a linked device, not Meta's Cloud API, so it suits conversations and team workflows rather than high-volume automated traffic. Keep the phone that owns the number active, since WhatsApp logs out linked devices after a long time offline.

Read the API docs · Get started

FAQ

Can I poll the WhatsApp API for new messages instead of using webhooks?

No. The Cloud API has no endpoint for listing received messages, and Meta states there are no APIs for fetching historical webhook data. Inbound messages only reach you through the messages webhook, so your endpoint must be reliable and must store each payload when it arrives, before doing anything slower.

Why is the phone number missing from some inbound webhooks?

Since 2026, users can adopt WhatsApp usernames. For those users Meta omits the phone number unless your business number exchanged messages or calls with them in the last 30 days or they're in your contact book. The business-scoped user ID in user_id and from_user_id is always present, so key contacts on that.

How do I tell an incoming message from a status update?

Both arrive under the messages webhook field. Incoming customer messages sit in a messages array inside value, while updates about messages you sent (sent, delivered, read, failed) sit in a statuses array. Check which array is present, and loop over every entry and change, because Meta can batch many updates together.

How long can I download media a customer sent me?

The media URL in a webhook, or returned by a media ID lookup, expires after 5 minutes, but you can request a fresh one. Media IDs received in webhooks stay downloadable for 7 days. Copy files to your own storage promptly. Customers can send files up to 100 MB; larger ones trigger error 131052.

Do I receive WhatsApp group messages through the Cloud API?

Only from groups created through Meta's Groups API, which requires an Official Business Account; those messages include a group_id. The Cloud API can't see groups your number was already in. Tenashi, which links your existing number as a device, delivers group messages as message.received events alongside DMs.


Receiving well comes down to storing everything once and never assuming a field exists. If you'd rather have those messages land in a shared inbox your team already works from, read Tenashi's API docs or get started.