# 03 · Enviar mensajes

`POST /v1/messages` · alcance `messages:send` · función de licencia `api`.

La API **valida, reserva cuota y encola**; un worker envía a Meta. Por eso responde `202` con el mensaje en `queued`. El resultado final llega por [evento](05-recibir-eventos.md) o con `GET /v1/messages/{id}`.

## Campos comunes

| Campo | Obligatorio | Descripción |
| --- | --- | --- |
| `to` | sí | Teléfono con o sin `+`, espacios o guiones. Se normaliza a dígitos con prefijo de país (`34600111222`) |
| `type` | sí | `template`, `text`, `image`, `document`, `audio`, `video` o `interactive` |
| `phone_number_id` | no | `num_…`. Sin él se usa el número **predeterminado** del tenant |
| `external_ref` | no | Vuestra referencia (nº de cita, factura…). Vuelve en **todos** los eventos de este mensaje y se puede filtrar con `GET /v1/messages?external_ref=` |
| `contact` | no | `{ "external_ref", "display_name" }`: crea o actualiza el contacto en la misma llamada (así enlazáis el contacto de WhatsApp con vuestro cliente) |
| `priority` | no | `normal` (por defecto) o `bulk`. Los `bulk` se procesan después de los `normal` |

Cabecera recomendada: `Idempotency-Key` ([02](02-autenticacion-y-multitenant.md#idempotencia)).

## Plantilla (la única forma de escribir primero)

```json
{ "to": "+34600111222", "type": "template",
  "template": { "name": "recordatorio_cita", "language": "es", "variables": ["Ana", "15 de octubre"] } }
```

- `variables` es una **lista** para plantillas con `{{1}}`, `{{2}}`… y un **objeto** (`{"nombre": "Ana"}`) para plantillas con variables con nombre. Debe coincidir exactamente con lo que espera la plantilla, si no `422 template_variable_mismatch` (con `details.expected`, `missing`, `extra`).
- La plantilla tiene que estar `approved`: `409 template_not_approved`. Una que no existe: `404`.
- `body` de la respuesta ya trae el texto renderizado.

## Texto libre y resto de tipos (solo con la ventana de 24 h abierta)

WhatsApp solo permite escribir libremente durante **24 horas desde el último mensaje del contacto**. Fuera de ese plazo, `409 window_closed` (con `details.window_expired_at`) y hay que enviar una plantilla.

```json
{ "to": "+34600111222", "type": "text", "text": { "body": "Hola Ana, tu cita queda confirmada." } }
```

Consultad `window_open` y `window_expires_at` en la conversación (`GET /v1/conversations/{id}`) para saberlo antes.

### Archivos

1. Subid el archivo: `POST /v1/media` (`multipart/form-data`, campo `file`) → `{ "id": "med_…" }`.
2. Enviadlo:

```json
{ "to": "+34600111222", "type": "document", "document": { "media_id": "med_…", "filename": "factura-2026-118.pdf", "caption": "Tu factura" } }
```

También `image`, `video` y `audio` (no admiten `filename`; `audio` no admite `caption`). En lugar de `media_id` se puede usar un `link` público (imagen, vídeo y documento).

| Tipo | Formatos | Tamaño máximo |
| --- | --- | --- |
| Imagen | JPEG, PNG | 5 MB |
| Vídeo | MP4, 3GPP | 16 MB |
| Audio | AAC, MP4, MPEG, AMR, OGG | 16 MB |
| Documento | PDF, Word, Excel, PowerPoint, TXT | 100 MB |

Se comprueba el tipo declarado **y** el contenido real del archivo (`422` si no coinciden).

### Botones y listas

```json
{ "to": "+34600111222", "type": "interactive",
  "interactive": { "type": "button", "body": "¿Confirmas la cita del 15?",
                   "buttons": [{ "id": "yes", "title": "Sí" }, { "id": "no", "title": "No" }] } }
```

Hasta 3 botones (título ≤ 20 caracteres). Una lista admite hasta 10 filas por sección (título ≤ 24, descripción ≤ 72).

**Qué eligió el cliente.** Su respuesta llega como evento `whatsapp.message.received`, y el campo `reply` del mensaje lo trae ya resuelto, sin tener que abrir el `payload`:

```json
"type": "interactive", "body": "Sí",
"reply": { "id": "yes", "title": "Sí" }
```

`reply.id` es el `id` que pusisteis al botón (o a la fila) al enviarlo; usad ese id, no el título, para decidir. En una fila de lista llega también `description`. El botón de una **plantilla** con botones de respuesta rápida llega igual (`type: "button"`, `reply.id` = el *payload* del botón). En cualquier otro mensaje, `reply` es `null`.

### Botón de enlace y formularios (Flows)

```json
{ "to": "+34600111222", "type": "interactive",
  "interactive": { "type": "cta_url", "body": "Puedes pagar aquí.",
                   "cta_url": { "display_text": "Pagar", "url": "https://pagos.ejemplo.com/p/123" } } }

{ "to": "+34600111222", "type": "interactive",
  "interactive": { "type": "flow", "body": "Rellena el formulario.",
                   "flow": { "flow_id": "1234567890", "flow_cta": "Abrir", "screen": "FECHA", "data": { "servicio": "revision" } } } }
```

- `cta_url`: un botón que abre una dirección `https` (texto ≤ 20 caracteres).
- `flow`: abre un formulario de WhatsApp Flows **ya creado en la cuenta del cliente** (el gateway no los crea). Cuando el cliente lo envía, llega un mensaje `interactive` con `reply: { "id": "flow", "data": { … } }`, donde `data` es lo que contestó. Puede contener datos personales: ver «Datos sensibles».
- Los dos tipos, como el resto de mensajes interactivos, solo se pueden enviar con la ventana de 24 h abierta. Se han construido según la documentación de Meta y todavía **no se han probado contra un Meta real**.

### Mostrar «escribiendo…»

```bash
curl -X POST "$BASE_URL/v1/messages/msg_01a1.../typing" -H "Authorization: Bearer $API_KEY"      # 204
```

Marca como leído el mensaje **recibido** y muestra al cliente el indicador de escritura, que dura hasta 25 segundos o hasta que enviáis la respuesta. Usadlo cuando vayáis a tardar más de un segundo (por ejemplo, mientras consulta una IA). Repetirlo lo renueva. Necesita el permiso `messages:send`. Sobre un mensaje enviado por vosotros responde `409`.

### Datos sensibles que escribe el cliente

Si el cliente escribe algo que no debe quedar guardado (un DNI, por ejemplo), una vez validado podéis borrarlo:

```bash
curl -X POST "$BASE_URL/v1/messages/msg_01a1.../redact" -H "Authorization: Bearer $API_KEY"      # 200, body: "[dato redactado]"
```

Se necesita el permiso `conversations:write`. Sustituye el cuerpo del mensaje, vacía el `payload` y borra también las copias guardadas en los eventos aún no entregados y en el aviso original de Meta; la vista previa de la conversación pasa a `[dato redactado]`. Es idempotente y solo funciona con mensajes recibidos de texto, de botón o de formulario (`409` en el resto). **Qué no puede borrar:** lo que ya se entregó a vuestro receptor de eventos (borradlo vosotros), el contenido que Meta guarde y el chat en el móvil del cliente. Por eso conviene pedir el dato lo más tarde posible y no repetirlo en las respuestas.

## Validaciones, en el orden en que se aplican

| # | Comprobación | Error |
| --- | --- | --- |
| 1 | Formato del teléfono | `422 validation_failed` (`fields[0].path = /to`) |
| 2 | Hay un número conectado y la cuenta no está restringida | `409 number_not_ready` |
| 3 | La cuenta no está bloqueada por pago en Meta | `402 payment_required` |
| 4 | Plantilla existente, aprobada y con las variables correctas | `404` · `409 template_not_approved` · `422 template_variable_mismatch` |
| 5 | Ventana de 24 h abierta (todo salvo plantillas) | `409 window_closed` |
| 6 | Contacto no dado de baja (marketing) y, si el tenant lo exige, con consentimiento | `403 contact_opted_out` · `403 opt_in_required` |
| 7 | Cuota mensual de la licencia | `403 license_limit_reached` |

La cuota (7) se reserva **de forma atómica**: con envíos simultáneos nunca se supera el límite. Una repetición idempotente no gasta cuota.

## Ciclo de vida de un mensaje

```
queued ─▶ sending ─▶ sent ─▶ delivered ─▶ read
              └──────────────▶ failed (con error)
```

| Campo | Significado |
| --- | --- |
| `status` | Estado actual. Nunca retrocede |
| `queued_at`, `sent_at`, `delivered_at`, `read_at`, `failed_at` | Marcas de tiempo (un estado posterior completa las de los anteriores) |
| `wa_message_id` | Identificador del mensaje en WhatsApp |
| `billable`, `pricing_category`, `pricing_type` | Los informa Meta con la entrega: si se cobra y bajo qué categoría. Ver [08](08-licencias-limites-y-costes.md) |
| `error` | `{ code, title, detail, meta_code, retryable }` si falló |

### Cuando falla

- Un `failed` con `error.retryable: true` se puede reintentar: `POST /v1/messages/{id}/retry` (no gasta cuota).
- **`outcome_unknown`**: la conexión con Meta se cortó después de enviar el cuerpo y no hubo respuesta. El mensaje **pudo haberse enviado**. El gateway **no lo reintenta solo** para no duplicarlo; comprobad antes de reintentar.
- Los errores recuperables de Meta (límite de velocidad, caídas) el gateway los reintenta solo, con retroceso, hasta 5 veces.
- Catálogo completo: [referencia/errores.md](referencia/errores.md).

Ejemplo real de evento de fallo:

```json
{
  "id": "evt_01a1311df0de704693e89d6439364259",
  "data": {
    "error": {
      "code": "131049",
      "title": "This message was not delivered to maintain healthy ecosystem engagement.",
      "detail": "In order to maintain a healthy ecosystem engagement, the message failed to be delivered.",
      "meta_code": 131049,
      "retryable": false
    },
    "status": "failed",
    "message": {
      "id": "msg_01a1311de665738c9eba2c512081bb5b",
      "to": "34600888002",
      "body": "Hola Luis, te recordamos tu cita del 16 de octubre.",
      "from": null,
      "type": "template",
      "error": {
        "code": "131049",
        "title": "This message was not delivered to maintain healthy ecosystem engagement.",
        "detail": "In order to maintain a healthy ecosystem engagement, the message failed to be delivered.",
        "meta_code": 131049,
        "retryable": false
      },
      "status": "failed",
      "payload": {
        "to": "34600888002",
        "type": "template",
        "template": {
          "name": "recordatorio_cita",
          "language": {
            "code": "es"
          },
          "components": [
            {
              "type": "body",
              "parameters": [
                {
                  "text": "Luis",
                  "type": "text"
                },
                {
                  "text": "16 de octubre",
                  "type": "text"
                }
              ]
            }
          ]
        }
      },
      "read_at": null,
      "sent_at": "2026-10-12T17:16:45.746Z",
      "billable": null,
      "media_id": null,
      "direction": "outbound",
      "failed_at": "2026-10-12T17:16:43.000Z",
      "number_id": "num_01a131198cb47a84a44f24d7d370daae",
      "queued_at": "2026-10-12T17:16:45.531Z",
      "contact_id": "ctc_01a1311de65f7eb2a5af57d4dca41d47",
      "created_at": "2026-10-12T17:16:45.531Z",
      "template_id": "tpl_01a131198cdb72fbb5bb7c28cbdd35e7",
      "delivered_at": null,
      "external_ref": "cita-48214",
      "pricing_type": null,
      "origin_app_id": "app_01a131198c747270ac323426043d53b8",
      "wa_message_id": "wamid.FAKEc43353d1839a15",
      "conversation_id": "cnv_01a1311de66072529b61a1320b15c02b",
      "pricing_category": null,
      "sent_by_agent_id": null,
      "reply_to_message_id": null
    }
  },
  "type": "whatsapp.message.status",
  "tenant_id": "ten_01a131198c7c7185b88aad2746e7e573",
  "tenant_ref": "DEMO-202610121712-hpc",
  "occurred_at": "2026-10-12T17:16:43.000Z"
}
```

## Responder dentro de una conversación

Para bandejas de entrada propias: `POST /v1/conversations/{id}/messages` con el mismo cuerpo **sin** `to` ni `phone_number_id` (salen de la conversación). El mensaje queda firmado por el agente.

## Ritmo de envío

El gateway respeta los límites de Meta por vosotros: espacia los mensajes al mismo destinatario (Meta admite 1 cada ~6 s) y limita la velocidad por número. Si encoláis miles, irán saliendo; no hace falta que los espacies. Cada cliente tiene además un **límite de destinatarios únicos cada 24 h** que fija Meta (empieza en 250 y crece con la calidad y la verificación del negocio); `GET /v1/numbers` muestra `messaging_limit` y `quality_rating`.
