# Enviar mensaje de WhatsApp (/api/sendWhatsappMessage)

`POST /api/meta/whatsapp/send`

URL base: `https://resender.dev`

Envía texto **o** un adjunto por URL (nunca ambos) a un contacto desde
un número de WhatsApp conectado. El token del negocio se resuelve en el
servidor.

El body es el mismo que el de Messenger a propósito (ADR 0019): un
cliente que atiende los tres canales solo cambia la URL. `pageId` es el
`phone_number_id` del número conectado y `recipientId` es el teléfono
del contacto en formato internacional.

Destino en una de dos formas:
- `conversationId` solo, tomado del `conversation.id` del webhook.
- `pageId` + `recipientId`; si además viene `conversationId`, debe
  coincidir con ese par. El teléfono se normaliza a dígitos (sin `+`,
  espacios ni guiones), igual que el `wa_id` de los entrantes.

**Ventana de 24 horas.** Resender la evalúa localmente antes de llamar
a Meta: si el contacto no escribió en las últimas 24 horas (o nunca
escribió), responde `409` con `requiresTemplate: true` sin consumir
cuota. En ese caso hay que usar
`POST /api/meta/whatsapp/templates/send`.

**`Idempotency-Key` es obligatoria** en WhatsApp: un duplicado le llega
al teléfono del cliente final.

Texto: máximo 4096 caracteres. Adjuntos: `image`, `video` y `audio`
llegan con ese mismo tipo y se ven en el chat; `file` se envía como
`document` de Cloud API.

**Pie de foto.** `attachment.caption` (opcional, solo WhatsApp) es el
texto que se ve debajo del adjunto, en el mismo mensaje. Vale para
`image`, `video` y `file`; en `audio` responde `400`
(`attachment_caption_unsupported`) porque WhatsApp no lo muestra.
Máximo 1024 caracteres. Vacío o `null` es "sin pie de foto".

Resender no hospeda media: Meta descarga el
archivo desde la URL y lo cachea ~10 minutos, así que la URL no puede
ser de un solo uso ni caducar en segundos. Si el origen no responde, el
envío falla con `code: attachment_fetch_failed`.

## Autenticación

- **ApiKey**: Header `Authorization: Bearer <token>`. API key opaca del tenant, creada en **Settings → API Tokens**. Una key revocada, inexistente o malformada responde `401 {"error":"unauthorized"}`.

## Parámetros

| Nombre | En | Tipo | Requerido | Descripción |
| --- | --- | --- | --- | --- |
| `Idempotency-Key` | header | string | Sí | **Obligatoria en WhatsApp.** Clave elegida por el cliente (1–200 caracteres). Repetirla devuelve `200` con el resultado original y `resender.idempotentReplay: true`, sin volver a llamar a Meta ni consumir cuota. Ausente, vacía o de más de 200 caracteres responde `400`. (minLength: 1, maxLength: 200) |
| `x-request-id` | header | string | No | Identificador de correlación opcional que se copia a los logs del servidor. |

## Cuerpo de la petición

`application/json` (requerido)

Destino del envío. O `conversationId` solo, o `pageId` +
`recipientId` (opcionalmente con `conversationId`, que debe
coincidir con el par).

| Campo | Tipo | Requerido | Descripción |
| --- | --- | --- | --- |
| `conversationId` | string (uuid) | No | `conversation.id` del webhook. |
| `pageId` | string | No | `phone_number_id` del número conectado (`metaPageId` de la conexión). |
| `recipientId` | string | No | Teléfono del contacto en formato internacional con código de país: 8 a 15 dígitos, con o sin `+`, espacios o guiones. Se normaliza a dígitos, igual que el `wa_id`. |
| `reply` | string | No | Texto del mensaje. Máximo 4096 caracteres. Se envía sin vista previa de enlaces. (minLength: 1, maxLength: 4096) |
| `attachment` | any \| null | No | `image`, `video` y `audio` conservan su tipo; `file` se envía como `document`. Sin `filename`. |

Una de estas variantes:

**Variante 1**

Requiere: `reply`

**Variante 2**

Requiere: `attachment`

### Ejemplos

Ejemplo `texto` — Texto respondiendo a un webhook:

```json
{
  "conversationId": "6f0e5a2c-8a5e-4a3d-9c2b-1f2e3d4c5b6a",
  "reply": "¡Hola! Tu pedido #8841 ya salió."
}
```

Ejemplo `contacto` — Texto por pageId (phone_number_id) + recipientId (teléfono):

```json
{
  "pageId": "109876543210987",
  "recipientId": "+52 55 1234 5678",
  "reply": "¡Hola! Tu pedido #8841 ya salió."
}
```

Ejemplo `imagen` — Imagen por URL con pie de foto:

```json
{
  "conversationId": "6f0e5a2c-8a5e-4a3d-9c2b-1f2e3d4c5b6a",
  "attachment": {
    "type": "image",
    "url": "https://cdn.example.com/pedidos/8841.png",
    "caption": "Así quedó tu pedido"
  }
}
```

Ejemplo `adjunto` — Documento por URL:

```json
{
  "conversationId": "6f0e5a2c-8a5e-4a3d-9c2b-1f2e3d4c5b6a",
  "attachment": {
    "type": "file",
    "url": "https://cdn.example.com/facturas/8841.pdf"
  }
}
```

## Ejemplo de petición

```bash
curl -X POST "https://resender.dev/api/meta/whatsapp/send" \
  -H "Authorization: Bearer <token>" \
  -H "Idempotency-Key: <Idempotency-Key>" \
  -H "Content-Type: application/json" \
  -d '{"conversationId":"6f0e5a2c-8a5e-4a3d-9c2b-1f2e3d4c5b6a","reply":"¡Hola! Tu pedido #8841 ya salió."}'
```

## Respuestas

### 200

Meta aceptó el mensaje (o replay idempotente).

`application/json`

| Campo | Tipo | Requerido | Descripción |
| --- | --- | --- | --- |
| `error` | string | No | Solo cuando el envío falló (replay idempotente de un fallo). |
| `meta` | object \| null | Sí | Respuesta original de Graph (por ejemplo `message_id`, `recipient_id`). |
| `resender` | object | Sí |  |
| `resender.conversationId` | string (uuid) | Sí |  |
| `resender.messageId` | string (uuid) | Sí |  |
| `resender.status` | `"received"` \| `"sent"` \| `"failed"` | Sí |  |
| `resender.idempotentReplay` | `true` | No | Presente solo cuando se devolvió un resultado almacenado por `Idempotency-Key`. |

```json
{
  "meta": {
    "messaging_product": "whatsapp",
    "contacts": [
      {
        "input": "5215512345678",
        "wa_id": "5215512345678"
      }
    ],
    "messages": [
      {
        "id": "wamid.HBgNNTIxNTUxMjM0NTY3OBUCABEYEjBBQkNERUYxMjM0NTY3ODkwAA=="
      }
    ]
  },
  "resender": {
    "conversationId": "6f0e5a2c-8a5e-4a3d-9c2b-1f2e3d4c5b6a",
    "messageId": "0c9d2f6e-3b7a-4e1d-8f5c-2a1b3c4d5e6f",
    "status": "sent"
  }
}
```

### 400

Body inválido, destino incompleto, teléfono inválido, adjunto inválido, pie de foto inválido, texto demasiado largo o `Idempotency-Key` ausente o fuera de rango.

`application/json`

| Campo | Tipo | Requerido | Descripción |
| --- | --- | --- | --- |
| `error` | string | Sí |  |
| `code` | `"send_destination_missing"` \| `"send_destination_incomplete"` \| `"send_target_missing"` \| `"send_target_conflict"` \| `"attachment_type_invalid"` \| `"attachment_url_missing"` \| `"attachment_url_invalid"` \| `"attachment_caption_invalid"` \| `"attachment_caption_unsupported"` \| `"attachment_caption_too_long"` \| `"conversation_channel_mismatch"` \| `"invalid_recipient"` | No |  |
| `message` | string | No | Explicación legible, presente en los bloqueos de plan y cuota. |
| `meta` | object \| null | No | Respuesta original de Graph cuando Meta rechazó el envío. |
| `resender` | object | No |  |

Ejemplo `idempotencia`:

```json
{
  "error": "Idempotency-Key is required and must be a non-empty string of at most 200 characters"
}
```

Ejemplo `destino`:

```json
{
  "code": "send_destination_missing",
  "error": "missing destination: send conversationId, or pageId and recipientId"
}
```

Ejemplo `telefono`:

```json
{
  "code": "invalid_recipient",
  "error": "recipientId must be a phone number in international format (country code included): 8 to 15 digits, with or without \"+\", spaces or dashes"
}
```

Ejemplo `ambos`:

```json
{
  "code": "send_target_conflict",
  "error": "reply and attachment are mutually exclusive; send exactly one of them"
}
```

Ejemplo `largo`:

```json
{
  "error": "reply is too long: WhatsApp allows 4096 characters and this message is 4310"
}
```

Ejemplo `canal`:

```json
{
  "code": "conversation_channel_mismatch",
  "error": "conversation belongs to messenger; use /api/meta/send"
}
```

### 401

API key ausente, malformada, inexistente o revocada.

`application/json`

| Campo | Tipo | Requerido | Descripción |
| --- | --- | --- | --- |
| `error` | string | Sí |  |
| `code` | string | No | Código estable, presente solo en algunos errores. |
| `message` | string | No | Explicación legible, presente en los bloqueos de plan y cuota. |
| `meta` | object \| null | No | Respuesta original de Graph cuando Meta rechazó el envío. |
| `resender` | object | No |  |

```json
{
  "error": "unauthorized"
}
```

### 402

Cuota del período agotada. Se resuelve pagando o esperando el próximo período.

`application/json`

| Campo | Tipo | Requerido | Descripción |
| --- | --- | --- | --- |
| `error` | string | Sí |  |
| `code` | string | No | Código estable, presente solo en algunos errores. |
| `message` | string | No | Explicación legible, presente en los bloqueos de plan y cuota. |
| `meta` | object \| null | No | Respuesta original de Graph cuando Meta rechazó el envío. |
| `resender` | object | No |  |

```json
{
  "error": "quota_exceeded",
  "message": "You used the 3000 messages of your plan for this billing period. Upgrade your plan to resume sending."
}
```

### 403

Igual que en Messenger, más el caso en que el canal de WhatsApp no
está habilitado para la cuenta.

`application/json`

| Campo | Tipo | Requerido | Descripción |
| --- | --- | --- | --- |
| `error` | string | Sí |  |
| `code` | string | No | Código estable, presente solo en algunos errores. |
| `message` | string | No | Explicación legible, presente en los bloqueos de plan y cuota. |
| `meta` | object \| null | No | Respuesta original de Graph cuando Meta rechazó el envío. |
| `resender` | object | No |  |

Ejemplo `whatsapp`:

```json
{
  "error": "channel_not_enabled",
  "message": "whatsapp channel is not enabled"
}
```

Ejemplo `waitlist`:

```json
{
  "error": "account is on the waitlist"
}
```

Ejemplo `plan`:

```json
{
  "error": "plan_unavailable",
  "message": "We couldn't resolve your current billing period. Contact support at info@resender.dev."
}
```

### 404

La conversación no existe para este tenant o la cuenta no está conectada.

`application/json`

| Campo | Tipo | Requerido | Descripción |
| --- | --- | --- | --- |
| `error` | string | Sí |  |
| `code` | string | No | Código estable, presente solo en algunos errores. |
| `message` | string | No | Explicación legible, presente en los bloqueos de plan y cuota. |
| `meta` | object \| null | No | Respuesta original de Graph cuando Meta rechazó el envío. |
| `resender` | object | No |  |

Ejemplo `conversacion`:

```json
{
  "error": "conversation not found"
}
```

Ejemplo `messenger`:

```json
{
  "error": "Facebook page is not connected for this tenant"
}
```

Ejemplo `instagram`:

```json
{
  "error": "Instagram account is not connected for this tenant"
}
```

Ejemplo `whatsapp`:

```json
{
  "error": "WhatsApp number is not connected for this tenant"
}
```

### 409

La ventana de atención de 24 horas está cerrada. Resender lo resuelve
sin llamar a Meta y no consume cuota. Enviá una plantilla con
`POST /api/meta/whatsapp/templates/send` o esperá a que el contacto
escriba.

`application/json`

| Campo | Tipo | Requerido | Descripción |
| --- | --- | --- | --- |
| `error` | `"customer_service_window_closed"` | Sí |  |
| `requiresTemplate` | `true` | Sí |  |
| `templateSendingSupported` | `true` | Sí |  |
| `message` | string | Sí |  |

```json
{
  "error": "customer_service_window_closed",
  "requiresTemplate": true,
  "templateSendingSupported": true,
  "message": "This contact hasn't messaged the number in the last 24 hours, so WhatsApp only accepts approved template messages. Send one with POST /api/meta/whatsapp/templates/send, or wait for the contact to write again."
}
```

### 429

Límite por API key excedido. Esperá `Retry-After` segundos.

`application/json`

| Campo | Tipo | Requerido | Descripción |
| --- | --- | --- | --- |
| `error` | string | Sí |  |
| `code` | string | No | Código estable, presente solo en algunos errores. |
| `message` | string | No | Explicación legible, presente en los bloqueos de plan y cuota. |
| `meta` | object \| null | No | Respuesta original de Graph cuando Meta rechazó el envío. |
| `resender` | object | No |  |

```json
{
  "error": "rate_limited"
}
```

### 502

No se pudo alcanzar Cloud API (error de red o timeout). Reintentá con la misma `Idempotency-Key`.

`application/json`

Meta rechazó el envío. El status HTTP es el que devolvió Cloud API.

| Campo | Tipo | Requerido | Descripción |
| --- | --- | --- | --- |
| `error` | string | Sí | Solo cuando el envío falló (replay idempotente de un fallo). |
| `meta` | object \| null | Sí | Respuesta original de Graph (por ejemplo `message_id`, `recipient_id`). |
| `resender` | object | Sí |  |
| `resender.conversationId` | string (uuid) | Sí |  |
| `resender.messageId` | string (uuid) | Sí |  |
| `resender.status` | `"received"` \| `"sent"` \| `"failed"` | Sí |  |
| `resender.idempotentReplay` | `true` | No | Presente solo cuando se devolvió un resultado almacenado por `Idempotency-Key`. |
| `code` | `"attachment_fetch_failed"` | No | Solo cuando Meta no pudo descargar el adjunto (131053). |

```json
{
  "error": "Could not reach WhatsApp's Cloud API (network error or timeout). Retry shortly.",
  "meta": null,
  "resender": {
    "conversationId": "6f0e5a2c-8a5e-4a3d-9c2b-1f2e3d4c5b6a",
    "messageId": "0c9d2f6e-3b7a-4e1d-8f5c-2a1b3c4d5e6f",
    "status": "failed"
  }
}
```

### default

Meta rechazó el envío. El status HTTP es el de Cloud API (típicamente
`400`). `error` trae el motivo traducido y `meta` la respuesta
original. Casos frecuentes: `131047` ventana cerrada, `131026`
destinatario sin WhatsApp, `131053` adjunto no descargable
(`code: attachment_fetch_failed`), `131042` WABA sin método de pago,
`130429` throughput excedido, `190` token vencido (hay que reconectar
el número), `132000`/`132001`/`132012`/`132015` errores de plantilla.

`application/json`

Meta rechazó el envío. El status HTTP es el que devolvió Cloud API.

| Campo | Tipo | Requerido | Descripción |
| --- | --- | --- | --- |
| `error` | string | Sí | Solo cuando el envío falló (replay idempotente de un fallo). |
| `meta` | object \| null | Sí | Respuesta original de Graph (por ejemplo `message_id`, `recipient_id`). |
| `resender` | object | Sí |  |
| `resender.conversationId` | string (uuid) | Sí |  |
| `resender.messageId` | string (uuid) | Sí |  |
| `resender.status` | `"received"` \| `"sent"` \| `"failed"` | Sí |  |
| `resender.idempotentReplay` | `true` | No | Presente solo cuando se devolvió un resultado almacenado por `Idempotency-Key`. |
| `code` | `"attachment_fetch_failed"` | No | Solo cuando Meta no pudo descargar el adjunto (131053). |

Ejemplo `parametros`:

```json
{
  "error": "The number of parameters sent doesn't match the variables the template defines. Send one value for each {{n}} variable in the template, in order.",
  "meta": {
    "error": {
      "message": "(#132000) Number of parameters does not match the expected number of params",
      "type": "OAuthException",
      "code": 132000
    }
  },
  "resender": {
    "conversationId": "6f0e5a2c-8a5e-4a3d-9c2b-1f2e3d4c5b6a",
    "messageId": "0c9d2f6e-3b7a-4e1d-8f5c-2a1b3c4d5e6f",
    "status": "failed"
  }
}
```

Ejemplo `adjunto`:

```json
{
  "code": "attachment_fetch_failed",
  "error": "Meta couldn't download the media from its URL. Make sure the URL is publicly reachable over https, without auth and without broken redirects, and that the file type and size are supported.",
  "meta": {
    "error": {
      "message": "(#131053) Media upload error",
      "type": "OAuthException",
      "code": 131053
    }
  },
  "resender": {
    "conversationId": "6f0e5a2c-8a5e-4a3d-9c2b-1f2e3d4c5b6a",
    "messageId": "0c9d2f6e-3b7a-4e1d-8f5c-2a1b3c4d5e6f",
    "status": "failed"
  }
}
```