Receiving WhatsApp contact cards
contact_cards is the one arm that carries the same field in both directions. A contact can share a card from their address book, and a tap on a contact info request you sent arrives here too, carrying the number they chose to disclose.
What an inbound contact card carries
contact_cards is always an array, and origin says how the card arrived:
Ejemplo de código
{
"id": "wam_01kyg9v3timy1w7n0r4cbh9ukf",
"direction": "inbound",
"from": { "phone_number": "+14155550100" },
"to": { "phone_number": "+13124495569" },
"status": "received",
"contact_cards": [
{
"origin": "contact_request",
"phone_numbers": [{ "phone_number": "+14155550100", "type": "cell" }]
}
],
"created_at": "2026-08-25T09:27:45Z"
}| origin | How the card arrived |
|---|---|
| contact_request | The contact tapped a button you sent asking for their number |
| other | The contact shared a card in the chat, unprompted |
Check origin before treating a card as an answer to your ask. It is the only signal that distinguishes the two, and a card shared unprompted can name a third party entirely rather than the contact. The value list is open, so treat one you do not recognize as another way of sharing added since.
A card this workspace sent reads back with no origin at all, which is how an outbound card is told from an inbound one on the same field.
What a tap carries, and what a shared card carries
The two arrive with different amounts of detail, and nothing on a card is required: WhatsApp sends the parts the card holds and omits the rest, so a card carrying only an origin still arrives rather than being dropped.
| Field | On a button tap | On a card shared in the chat |
|---|---|---|
| phone_numbers[].phone_number, type | The number the contact chose to disclose | Whatever numbers the card holds |
| vcard | Omitted; a tap carries the number alone | The card in vCard format |
| name, org, birthday, emails, urls, addresses | Whatever WhatsApp sends, which is usually nothing | Present when the card holds them |
Ejemplo de código
{
"contact_cards": [
{
"origin": "other",
"vcard": "BEGIN:VCARD\nVERSION:3.0\nN:Johnson;Barbara;;;\nTEL;type=CELL:+16505551234\nEND:VCARD\n",
"name": {
"formatted_name": "Barbara J. Johnson",
"first_name": "Barbara",
"last_name": "Johnson"
},
"org": { "company": "Northside Plumbing" },
"phone_numbers": [{ "phone_number": "+16505551234", "type": "cell" }]
}
]
}Two fields need care when you parse them. phone_number is normalized to E.164 where it can be parsed, and passed through exactly as the contact's device stored it where it cannot, an extension among them, so parse defensively rather than assuming E.164. birthday comes off that device unvalidated and is passed through as text in YYYY-MM-DD shape rather than typed as a date, so do not assume it parses. A type label on a received card is lowercased, and WhatsApp defines no vocabulary for it, so match it case-insensitively rather than switching on CELL.
The phone number a contact discloses
A contact who has adopted a WhatsApp username reaches you by business-scoped user ID with no phone number on from. A contact info request is how you ask for the number, and this arm is where the answer arrives, with origin: "contact_request" and the number in phone_numbers.
The number they disclose is not guaranteed to be the number they chat from: Meta warns that a user's identifier and phone number may not always match, so store the disclosed number as its own fact rather than overwriting the identity on from.
The webhook payload
whatsapp.received carries the contact_cards array on the event envelope:
Ejemplo de código
{
"type": "whatsapp.received",
"timestamp": "2026-08-25T09:27:45.019Z",
"data": {
"whatsapp_id": "wam_01kyg9v3timy1w7n0r4cbh9ukf",
"workspace_id": "ws_01ky7m235keycbnwyajabe1a6b",
"direction": "inbound",
"from": { "phone_number": "+14155550100", "bsuid": "US.13491208655302741918" },
"to": { "phone_number": "+13124495569" },
"contact_cards": [
{
"origin": "contact_request",
"phone_numbers": [{ "phone_number": "+14155550100", "type": "cell" }]
}
],
"tags": null,
"metadata": null
}
}Things to watch
- A declined request produces nothing. WhatsApp shows the contact a share sheet, and dismissing it sends no message and fires no webhook, so a flow waiting on a number needs its own timeout rather than a decline event to watch for.
- Two outstanding asks are indistinguishable. A card answering a contact info request carries no in_reply_to_message_id, so a second ask sent before the first is answered cannot be matched to its own answer.
- The array can hold several cards. A contact sharing multiple cards in one message fills several entries, each with its own origin.
- A card is contact data you did not collect. It can carry a third party's name, numbers, and birthday, so apply the same retention and consent rules you would to any other personal data before storing it.
Next steps
- How receiving works: the inbound envelope, media fetching, and the whatsapp.received webhook
- WhatsApp contact cards: the send side of the same arm
- Business-scoped user IDs: why a contact arrives without a phone number, and how the ask fits the conversation
- WhatsApp contact info requests: the button that asks for a number