> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.chatbridge.mx/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.chatbridge.mx/_mcp/server.

# Changelog

Latest changes in the public API. Integrations that do not use the new fields
or features keep working unchanged, except where a change is marked as
**Behavior change**.

## 1. SMS — Get a message

New `GET /v1/sms/messages/{message_id}` endpoint that returns the current status
and data of a message. It works with every `message_id` you receive: a single
send, each number of a batch, or the `fallback_id` of a WhatsApp/RCS send with
SMS fallback.

```bash
curl -X GET https://connect.chatbridge.mx/v1/sms/messages/b3e1c2a4-9f3d-4b7a-8c1e-2a6f9d3b7e10 \
  -H "x-api-key: <your-api-key>"
```

```json
{
  "message_id": "b3e1c2a4-9f3d-4b7a-8c1e-2a6f9d3b7e10",
  "to": "5555555555",
  "message": "mensaje ejemplo Chatbridge.",
  "status": "SENT",
  "operator": "telcel",
  "created_at": "2026-10-19T17:00:00.000Z",
  "sent_at": "2026-10-19T17:00:01.000Z",
  "failed_at": null
}
```

* `status` uses the same values as the status webhook. `message` is the final
  text that was sent, including the short link when the send had a
  `complement`. `to` is the number as it was sent: 10 digits for a national
  mask, E.164 without `+` for an international one.
* `batch_id` is included only for numbers of a batch, and `origin_identifier`
  (the `message_id` of the original WhatsApp/RCS send) only for fallback SMS.
* Any SMS App-Key of the same account can query the message. A message from
  another account responds `404` with `errorCode` 591. A single send rejected
  with `400` is not stored, so its id also responds `404`.
* A `message_id` that is not a UUID responds `400` with `errorCode` 400.
* Each query counts toward the App-Key's rate limit like any other request.

See **SMS → Messages → Get message** for the full reference.

## 2. SMS — Specific errors for the destination number

`POST /v1/sms/messages` now tells you why the destination number was rejected.
Before, every rejection of `to` responded `errorCode` 352. Now:

* `352` is only for a national mask whose `to` is not 10 digits. A national
  mask also accepts the Mexico country code `52`/`+52` or the old mobile prefix
  `521`/`+521` (for example, `525555555555` or `+5215555555555`); the prefix is
  removed before sending and storing the number.
* `571`: international mask and `to` is not E.164. It must always include the
  country code, for example `+16462713111`, or `+525512345678` for Mexico.
* `572`: national mask and the number does not exist in the numbering plan.
* `573`: national mask and the number exists but is not a mobile line (for
  example, a landline).
* A temporary failure while checking the numbering plan now responds `500` and
  can be retried, instead of rejecting the number as invalid.
* Error messages are now in Spanish, like the rest of the SMS errors.

**Behavior change:** if your integration uses `errorCode` 352 to detect any
invalid number, also handle `571`, `572` and `573`.

In `POST /v1/sms/batches` nothing changes: rejected numbers are still reported
per number as `INVALID_FORMAT`, `INVALID_SOURCE` or `OUTPUT_NOT_FOUND`.

**SMS-specific errors**

| `errorCode` | HTTP | `message`                                                                                        | Cause                                                                                               |
| ----------- | ---- | ------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------- |
| `351`       | 400  | `Máscara inválida o no disponible para esta cuenta`                                              | `mask` does not exist or does not belong to the App-Key's account                                   |
| `352`       | 400  | `Número destino inválido: las máscaras nacionales requieren 10 dígitos, con o sin lada 52 o 521` | National mask and `to` is not 10 digits (with or without `52`/`+52` or `521`/`+521`)                |
| `353`       | 400  | `La máscara no tiene salida configurada para el operador del destinatario`                       | The mask has no output for the recipient's operator                                                 |
| `355`       | 400  | `La máscara está deshabilitada`                                                                  | The mask exists but is disabled                                                                     |
| `571`       | 400  | `Número destino inválido: las máscaras internacionales requieren formato E.164`                  | International mask and `to` is not E.164                                                            |
| `572`       | 400  | `Número destino inválido: no existe en el plan de numeración`                                    | National mask and the number is not in the numbering plan                                           |
| `573`       | 400  | `El número destino no es una línea móvil`                                                        | National mask and the number is not a mobile line                                                   |
| `591`       | 404  | `El mensaje no existe o no pertenece a la cuenta`                                                | `GET /v1/sms/messages/{message_id}`: unknown message or from another account                        |
| `500`       | 500  | `Internal Server Error.`                                                                         | Unhandled internal error, including a temporary failure checking the numbering plan; can be retried |

```json
{
  "message": "El número destino no es una línea móvil",
  "error": "Bad Request",
  "statusCode": 400,
  "errorCode": 573
}
```

See **SMS → Messages → Send message** for the full reference.

## 3. SMS — Short link validity range (`complement.ttl_events`)

`complement.ttl_events` (minutes during which clicks on the short link are
recorded) now accepts values from `60` (1 hour) to `4320` (72 hours). The
default is still `1440` (24 hours) when it is omitted.

The range applies to:

* `POST /v1/sms/messages`
* `POST /v1/sms/batches`, both the batch `complement` and each number's
* `fallback.sms.complement` in WhatsApp and RCS sends

A value outside the range responds `400` with `errorCode` 400:

```json
{
  "message": "complement.ttl_events no puede exceder 4320 minutos (72 horas)",
  "error": "Bad Request",
  "statusCode": 400,
  "errorCode": 400
}
```

**Behavior change:** values below `60` or above `4320` used to be accepted and
are now rejected.

## 4. RCS — Batch sends

New `POST /v1/rcs/batches` endpoint to send the same RCS content (a template or
free-form content) to up to 250 recipients in a single request, personalizing
each number's variables. It responds `202` immediately with a `batch_id` and one
`message_id` per number (in the order of the request); the batch is processed in
the background.

* The content and delivery settings (`agent_id`, `type`, `template_id`, `text`,
  `contentInfo`, `richCard`, `suggestions`, `ttl_message`, `ttl_events`,
  `fallback`) go once at the root; each item of `messages` carries only its `to`
  and `variables`.
* Content validations run once and reject the whole batch if any fails; numbers
  rejected by destination (`INVALID_FORMAT`, `INVALID_SOURCE`,
  `OUTPUT_NOT_FOUND`) do not stop the others.
* `webhook_mode: INDIVIDUAL` (default) keeps the regular per-number webhooks now
  with `batch_id`; `webhook_mode: BATCH` sends a single `batchStatus` webhook
  with the result of every number.
* A batch counts as a single request for the rate limit.

```bash
curl -X POST https://connect.chatbridge.mx/v1/rcs/batches \
  -H "x-api-key: <your-api-key>" \
  -H "Content-Type: application/json" \
  -d '{
    "agent_id": "66f0c2a1e4b0a1b2c3d4e5f6",
    "template_id": "66f0c2a1e4b0a1b2c3d4e5f7",
    "webhook_mode": "BATCH",
    "messages": [
      { "to": "5555555555", "variables": { "nombre": "Ana", "pedido": "123" } },
      { "to": "5544444444", "variables": { "nombre": "Luis", "pedido": "456" } }
    ]
  }'
```

See **RCS → Messages → Send batch** and **RCS → Webhooks → Batch status** for the
full reference.

## 5. WhatsApp — Batch sends

New `POST /v1/whatsapp/batches` endpoint to send WhatsApp messages to up to 250
recipients in a single request. It responds `202` immediately with a `batch_id`
and one `message_id` per recipient (in the order of the request); the messages
are sent in the background.

* `type` goes at the root and each item of `messages` carries its own `to` and
  the content of that type (`text`, `image`, `template`...). All 11 message
  types are supported.
* `fallback.sms` at the root is the SMS fallback of every recipient; each item of
  `messages` can carry its own `fallback.sms`, which replaces the root one for
  that recipient. The response returns a `fallback_id` per recipient with
  fallback.
* `webhook_mode: INDIVIDUAL` (default) keeps the regular status webhook per
  recipient, now with `batch_id`; `webhook_mode: BATCH` sends a single
  `batchStatus` webhook with the status of every recipient.
* Media URLs are validated against public destinations only: URLs pointing to
  private networks are rejected with `errorCode` 575, also in
  `POST /v1/whatsapp/messages`.
* A batch counts as a single request for the rate limit.

```bash
curl -X POST https://connect.chatbridge.mx/v1/whatsapp/batches \
  -H "x-api-key: <your-api-key>" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "TEXT",
    "messages": [
      { "to": "+5215512345678", "text": { "body": "Hola Ana, tu pedido va en camino." } },
      { "to": "+5215587654321", "text": { "body": "Hola Luis, tu pedido va en camino." } }
    ]
  }'
```

See **WhatsApp → Messages → Send batch** and **WhatsApp → Webhooks → Batch
status** for the full reference.

## 6. Email — Transactional sends

New Email channel: `POST /v1/email/messages` sends an email from a template of
your account. The send is asynchronous: it responds `202` with a `message_id`,
and the events (`SENT`, `DELIVERED`, `BOUNCED`, `OPENED`, `CLICKED`...) are
notified through webhooks. The App-Key is created with the sender (mailing
output) linked, so the request only carries the recipient, the template, the
subject, and the template variables. `GET /v1/email/sender` and
`GET /v1/email/templates` let you query the sender and the templates (with the
variables each one expects).

```bash
curl -X POST https://connect.chatbridge.mx/v1/email/messages \
  -H "x-api-key: <your-api-key>" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "customer@example.com",
    "template_id": "6a4bffd5e77672e33fd53050",
    "subject": "Your order is confirmed",
    "variables": { "name": "Ana", "order_number": 10482 }
  }'
```

Variable values are HTML-escaped, and every placeholder of the template must be
sent. See the [Email API reference](/email) for the full contract and the
webhook payload.

## 7. SMS — Batch sends

New `POST /v1/sms/batches` endpoint to send SMS to up to 250 numbers in a
single request. It responds `202` immediately with a `batch_id` and one
`message_id` per number (in the order of the request); the numbers are
processed in the background.

```bash
curl -X POST https://connect.chatbridge.mx/v1/sms/batches \
  -H "x-api-key: <your-api-key>" \
  -H "Content-Type: application/json" \
  -d '{
    "mask": "6a4bffd5e77672e33fd5304f",
    "text": "Tu pedido va en camino.",
    "webhook_mode": "BATCH",
    "messages": [
      { "to": "5555555555" },
      { "to": "5544444444", "text": "Tu pedido llegó a la sucursal.", "has_accents": true },
      { "to": "5533333333", "complement": { "link": "https://mitienda.mx/pedidos/5533333333" } }
    ]
  }'
```

* `text`, `has_accents` and `complement` at the root are the batch defaults;
  each number can override any of them. A number's `complement` replaces the
  batch one entirely, and every number with a `complement` gets its own short
  link.
* An invalid number does not reject the batch: it is reported per number as
  `INVALID_FORMAT`, `INVALID_SOURCE` or `OUTPUT_NOT_FOUND`.
* `webhook_mode: INDIVIDUAL` (default) keeps the regular status webhook per
  number, now with `batch_id`. `webhook_mode: BATCH` sends a single
  `batchStatus` webhook with every number's status once all of them have a
  send result; later changes arrive as per-message webhooks.
* A batch counts as one request for the rate limit.

See **SMS → Messages → Send batch** and **SMS → Webhooks → Batch status** for the full reference.

## 8. OAuth 2.0 client credentials

Besides the `x-api-key` header, every endpoint now accepts an **OAuth 2.0
access token** in the `Authorization: Bearer` header. The token is obtained
from the new `POST /api/oauth/token` endpoint (grant `client_credentials`)
with the `client_id` / `client_secret` pair the Chat Bridge team issues for
your App-Key.

```bash
curl -X POST https://connect.chatbridge.mx/api/oauth/token \
  -H "Content-Type: application/json" \
  -d '{"grant_type":"client_credentials","client_id":"ck_...","client_secret":"..."}'
```

```json
{ "access_token": "eyJhbGciOi...", "token_type": "Bearer", "expires_in": 3600 }
```

* Tokens last 1 hour. Request a new one before it expires and reuse it: the
  token endpoint is limited to 30 requests per minute per IP, and 5 failed
  attempts block the IP for 15 minutes.
* Credentials can go in the JSON body, as `application/x-www-form-urlencoded`,
  or as `Authorization: Basic base64(client_id:client_secret)`.
* The token identifies the same App-Key: account, channel, webhooks and rate
  limit do not change. If both headers are sent, `Bearer` wins.
* When a secret is rotated the previous one keeps working for 24 hours.
* An App-Key can be switched to OAuth only; from then on `x-api-key` responds
  `401` with `errorCode` 450.

See [OAuth 2.0](/authentication/oauth/issue-access-token) in the Authentication section for the full reference.

## 9. Tools — Short Links

New **Tools → Short Links** section with a URL shortener on the `sl2.mx`
domain, available to every App-Key regardless of its channel:

* `POST /shortlinks` shortens an absolute `http`/`https` URL (up to 2048
  characters) and returns a 10-character `identifier` plus the ready-to-use
  `short_link`. Links expire after a fixed 3 days.
* `GET /shortlinks/{code}` returns whether the link is still active, its
  creation and expiry dates and the accumulated `total_clicks`. Only the user
  of the App-Key that created the link can query it (`403`, `errorCode` 484
  otherwise; `404`, `errorCode` 483 if the code does not exist).

```bash
curl -X POST https://connect.chatbridge.mx/shortlinks \
  -H "x-api-key: YOUR_APP_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://www.example.com/tracking/1234"}'
```

```json
{ "identifier": "aB3dE5gH7j", "url": "https://www.example.com/tracking/1234", "short_link": "sl2.mx/aB3dE5gH7j" }
```

Invalid URLs respond `400` with `errorCode` 482 and `metadata.reason`
(`url_required`, `url_too_long`, `url_not_absolute`, `invalid_scheme`,
`url_control_chars`).

## Previous changes (WhatsApp and RCS)

## 10. WhatsApp — Send by BSUID

Besides the E.164 number, `POST /v1/whatsapp/messages` now accepts a **BSUID**
(Business Solution User ID) in the `to` field. A BSUID is the anonymous
identifier Meta assigns to a contact when the conversation comes from a
"Click to WhatsApp" flow or another channel where Meta does not expose the
user's real number — it lets you reply without knowing their phone. Inbound
webhooks carry it in `message.from_user_id`.

| Format | Pattern                          | Example               |
| ------ | -------------------------------- | --------------------- |
| E.164  | `^\+[1-9]\d{1,14}$`              | `+5215512345678`      |
| BSUID  | `^[A-Z]{2}\.[A-Za-z0-9]{1,128}$` | `MX.1264908862298277` |

```json
{
  "to": "MX.1264908862298277",
  "type": "TEXT",
  "text": { "body": "Hola, gracias por contactarnos" }
}
```

If `to` matches neither format, the API rejects with `400`. The format applies
equally to template sends (`type: TEMPLATE`) and every other message type.

## 11. WhatsApp — Automatic SMS fallback

`POST /v1/whatsapp/messages` now admits an optional fallback to SMS: if the
WhatsApp send fails definitively (synchronously or through a status webhook),
the API automatically retries the same recipient over SMS — without the
integrator having to detect the failure and resend.

Each send decides whether it wants the fallback and with which content, in the
new `fallback` field:

```json
{
  "to": "+5215512345678",
  "type": "TEMPLATE",
  "template": { "...": "..." },
  "fallback": {
    "sms": {
      "mask": "665f1a2b3c4d5e6f7a8b9c0d",
      "text": "No pudimos contactarte por WhatsApp. Tu pedido #1234 está confirmado.",
      "has_accents": true,
      "complement": {
        "link": "https://midominio.com/tracking/1234",
        "ttl_events": 1440
      }
    }
  }
}
```

`fallback.sms` uses exactly the same shape as the body of
`POST /v1/sms/messages` (`mask`, `text`, `has_accents`, `complement`)
**except** `to` — the recipient is inherited from the original WhatsApp
message. The `mask` is validated at the moment of the original send (an
invalid or disabled mask rejects the whole request with `400` before
attempting WhatsApp). The fallback fires only **once** per message (atomic
lock) and it is a single hop: WhatsApp → SMS, no further cascade.

The feature must be enabled for the account by whoever manages it:

| HTTP | `errorCode` | Description                                                                                          |
| ---- | ----------- | ---------------------------------------------------------------------------------------------------- |
| 403  | 380         | `APP_KEY_FALLBACK_REQUIRES_POSTPAID` — the multichannel fallback requires the channel to be postpaid |
| 403  | 381         | `APP_KEY_FALLBACK_ACCOUNT_DISABLED` — the account does not have the multichannel fallback enabled    |

**Synchronous response when the fallback activates.** If the send fails
synchronously and the request included `fallback.sms`, the `201` response
includes `fallback_id` — the identifier to correlate the backup SMS in the
webhooks:

```json
{
  "message_id": "",
  "status": "queued",
  "fallback_id": "b3f1c2d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d"
}
```

**Webhooks.** The status events of the backup SMS arrive as **SMS** webhooks
carrying `origin_identifier` — the `message_id` of the WhatsApp send that
originated the fallback. If the backup SMS also fails (or could not be
generated, e.g. the `to` was a BSUID with no real phone behind it), a final
*Fallback exhausted* event is emitted:

```json
{
  "message_id": "b3f1c2d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
  "status": "FALLBACK_EXHAUSTED",
  "origin_identifier": "0f8e7d6c-5b4a-3928-1706-f5e4d3c2b1a0"
}
```

See the *Fallback exhausted* webhook in the WhatsApp API reference for the
`message_id` / `origin_identifier` semantics of each case.

## 12. RCS — Automatic SMS fallback

Same mechanism as section 11, applied to `POST /v1/rcs/messages`: if the RCS
message is not delivered, the API automatically retries over SMS.

```json
{
  "to": "5512345678",
  "template_id": "665f1a2b3c4d5e6f7a8b9c0d",
  "agent_id": "665f1a2b3c4d5e6f7a8b9c9a",
  "variables": { "nombre": "Iván" },
  "fallback": {
    "sms": {
      "mask": "665f1a2b3c4d5e6f7a8b9c0d",
      "text": "No pudimos contactarte por RCS. Tu pedido #1234 está confirmado."
    }
  }
}
```

What fires it:

* The RCS **delivery status** webhook reports `FAILED` or `EXPIRED` for the
  message.
* A late `FAILED`/`EXPIRED` that the RCS status priority logic already
  discards (a suppressed downgrade, an out-of-order event) does **not** fire
  the fallback — only a failure that actually advances the message status
  does.
* Unlike WhatsApp, RCS has no synchronous failure point (the send always
  responds `202 Accepted`) — the RCS fallback fires only through webhooks.

Webhooks work as in section 11: the backup SMS statuses arrive as SMS webhooks
with `origin_identifier`, and the chain ends with a *Fallback exhausted*
event. The `mask` of the fallback is validated when sending the original RCS
message (fast `400`), not when the fallback fires hours later.

## 13. RCS — Free-form sends (no template)

`POST /v1/rcs/messages` now accepts the **message content directly in the
body**, without a pre-created and approved template. The mode is selected
with the new discriminator field `type`:

| `type`               | Mode                                          | Required content field    |
| -------------------- | --------------------------------------------- | ------------------------- |
| `TEMPLATE` (default) | Template-based — previous behavior, unchanged | `template_id`             |
| `TEXT`               | Free-form: plain text                         | `text`                    |
| `FILE`               | Free-form: one file (image/video)             | `contentInfo`             |
| `RICH_CARD`          | Free-form: single rich card                   | `richCard.standaloneCard` |
| `CAROUSEL`           | Free-form: carousel of 2 to 10 cards          | `richCard.carouselCard`   |

If `type` is omitted, `TEMPLATE` is assumed: existing integrations do not
change.

```json
{
  "to": "5512345678",
  "type": "TEXT",
  "agent_id": "665f1a2b3c4d5e6f7a8b9c9a",
  "text": "Hola {{nombre}}, tu pedido va en camino.",
  "variables": { "nombre": "Iván" },
  "suggestions": [{ "reply": { "text": "Rastrear" } }]
}
```

Rules to keep in mind:

1. `template_id` is **forbidden** (400) with any free-form `type`, and
   required with `TEMPLATE`.
2. `variables` also applies to free-form content: the `{{placeholders}}`
   written in `text`, `contentInfo`, or `richCard` are substituted with the
   same type rules (URL in `fileUrl`/`thumbnailUrl`/`iconUrl`/`url`, free
   text elsewhere) and the same HEAD media validation.
3. `variables.UUID_MESSAGE` is a **reserved name**, now rejected explicitly
   with `400` in the five types (before, in the template path, it was
   silently overwritten — the only behavior change in the `TEMPLATE` flow).
4. One content per request → 1 credit per free-form send.
5. The billing channel derives from the **agent**, not the content: a
   `CONVERSATIONAL` agent bills as `RCS_CONVERSATIONAL` whether it sends
   text or a rich card; same for the conversation window.
6. Root-level `suggestions` (message-level chips, max 11) are independent
   from the ones embedded inside a card, and can be combined. Never send
   `postbackData` — the backend generates it.
7. Agent ↔ content compatibility (replaces `RCS_TYPE_MISMATCH`, which stays
   exclusive to `TEMPLATE` mode):

| Agent type       | `TEXT` | `FILE` | `RICH_CARD` | `CAROUSEL` | `suggestions` |
| ---------------- | ------ | ------ | ----------- | ---------- | ------------- |
| `BASIC`          | ✅      | ❌      | ❌           | ❌          | ❌             |
| `SINGLE`         | ✅      | ✅      | ✅           | ✅          | ✅             |
| `CONVERSATIONAL` | ✅      | ✅      | ✅           | ✅          | ✅             |

New errors:

| HTTP | `errorCode` | Code                                     | When                                                                                                |
| ---- | ----------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------- |
| 400  | 376         | `RCS_CONTENT_TYPE_NOT_ALLOWED_FOR_AGENT` | The content `type` is not compatible with the agent's type (e.g. `RICH_CARD` with a `BASIC` agent). |
| 400  | 377         | `RCS_SUGGESTIONS_NOT_ALLOWED`            | `suggestions` were sent with a `BASIC` agent, which has no buttons.                                 |

Unchanged: the `202 Accepted` response, the webhooks (same envelope, same
correlation by `message_id`, clicks on free-form `suggestions` arrive
classified with `action`/`element_type`/`element_value` exactly like template
ones), and the agents/templates endpoints. In the App-Key CSV report,
free-form sends show `template_name` = **`Envío libre`**, with the full
content (including `type`) in the `data` column.

## 14. WhatsApp — Inbound `contacts` messages (vCard)

The *Inbound message* webhook supports a new content type: `contacts`. It
fires when a user shares one or more contacts (vCard) in the conversation.

**`message.type`** can now be `"contacts"` (previously: `text`, `image`,
`video`, `document`, `audio`, `button`, `location`).

**New `message.contacts` field** (array): the shared contacts. Every field is
optional — Meta omits the ones the user did not share from their address book.

| Field                                 | Type   | Description                                   |
| ------------------------------------- | ------ | --------------------------------------------- |
| `contacts[].name.formatted_name`      | string | Full formatted name, as displayed in WhatsApp |
| `contacts[].name.first_name`          | string | First name                                    |
| `contacts[].name.last_name`           | string | Last name                                     |
| `contacts[].name.middle_name`         | string | Middle name                                   |
| `contacts[].name.prefix`              | string | Name prefix (e.g. "Sr.", "Dra.")              |
| `contacts[].name.suffix`              | string | Name suffix (e.g. "Jr.", "III")               |
| `contacts[].org.company`              | string | Company associated with the contact           |
| `contacts[].org.department`           | string | Department within the company                 |
| `contacts[].org.title`                | string | Contact's job title                           |
| `contacts[].phones[].phone`           | string | Phone number in human-readable format         |
| `contacts[].phones[].wa_id`           | string | WhatsApp identifier of the phone number       |
| `contacts[].phones[].type`            | string | Phone type (`CELL`, `WORK`, `HOME`, etc.)     |
| `contacts[].emails[].email`           | string | Contact's email address                       |
| `contacts[].emails[].type`            | string | Email type (`WORK`, `HOME`, etc.)             |
| `contacts[].urls[].url`               | string | URL associated with the contact               |
| `contacts[].urls[].type`              | string | URL type (`WORK`, `HOME`, etc.)               |
| `contacts[].addresses[].street`       | string | Street                                        |
| `contacts[].addresses[].city`         | string | City                                          |
| `contacts[].addresses[].state`        | string | State/province                                |
| `contacts[].addresses[].zip`          | string | Postal code                                   |
| `contacts[].addresses[].country`      | string | Country                                       |
| `contacts[].addresses[].country_code` | string | Country code (ISO)                            |
| `contacts[].addresses[].type`         | string | Address type (`WORK`, `HOME`, etc.)           |
| `contacts[].birthday`                 | string | Birth date, `YYYY-MM-DD` format               |

**Note**: in real traffic we have also observed the fields `vcard` (the full
vCard in Base64) and `origin` (e.g. `"other"`), which are not part of Meta's
officially documented schema but do arrive in production. Those fields — and
any other not listed above — are propagated as-is: do not assume a closed
shape.

**Example:**

```json
{
  "phone_number_id": "123456789012345",
  "message_id": "wamid.HBgNNTIxNTU1MTIzNDU2NxUCABIYFjNFQjBDMTAyRjg5NzRBMEE4RUNE",
  "type": "message",
  "message": {
    "from": "5215551234567",
    "from_user_id": "MX.1264908862125796",
    "timestamp": "1716249600",
    "type": "contacts",
    "status": "reply",
    "contacts": [
      {
        "name": {
          "formatted_name": "Juan Pérez",
          "first_name": "Juan",
          "last_name": "Pérez"
        },
        "org": {
          "company": "Acme Corp"
        },
        "phones": [
          {
            "phone": "+52 55 1234 5678",
            "wa_id": "5215512345678",
            "type": "CELL"
          }
        ],
        "vcard": "RkVzSU=="
      }
    ],
    "context": {
      "message_id": "wamid.HBgNNTIxNTU1MTIzNDU2NxUCABEYEjNFQjBDMTAyRjg5NzRBMEE4RQ=="
    }
  }
}
```