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:
- A verified endpoint. Your URL must answer Meta's verification
GETwith the challenge value and present a valid TLS certificate (self-signed certificates are rejected). - The
messagesfield subscribed in App Dashboard, WhatsApp, Configuration. Receiving it needs thewhatsapp_business_messagingpermission. - 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_appscall.
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:
| Field | What it tells you | What to do with it |
|---|---|---|
entry[].id | The WhatsApp Business Account ID | Useful if one app serves several accounts |
metadata.phone_number_id | Which of your numbers received it | Route to the right team, and reply from the same number |
contacts[].profile.name | The name on their WhatsApp profile | Display only; people change it |
contacts[].user_id, messages[].from_user_id | The business-scoped user ID (BSUID) | Your stable key for the person |
messages[].from, contacts[].wa_id | Their phone number, when Meta shares it | Store it, but don't require it |
messages[].id | The message's wamid | Your de-duplication key and the ID you quote when replying |
messages[].timestamp | Unix seconds, as a string | Convert and store; don't trust arrival order |
messages[].type | Which object holds the content | Branch 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:
type | Where the useful content is | Worth knowing |
|---|---|---|
text | text.body | context.forwarded or frequently_forwarded flags forwarded messages |
image, video, document | id, mime_type, sha256, optional caption | Documents also carry filename |
audio | id, mime_type, voice | voice is true for recorded voice notes |
sticker | id, mime_type | WebP |
interactive | button_reply.id or list_reply.id, plus title | The id is the one you set when you built the button |
button | button.payload, button.text | A quick-reply tap on a template you sent |
reaction | reaction.message_id, reaction.emoji | No emoji means the user removed their reaction |
location | latitude, longitude, optional name, address | |
system | system.type | user_changed_number or user_changed_user_id; no contacts array |
unsupported | errors | A 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
systemmessages of typeuser_changed_user_id: the payload carriesprevious_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.