# Enviar plantilla de WhatsApp (/api/sendWhatsappTemplate)

`POST /api/meta/whatsapp/templates/send`

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

Envía una plantilla aprobada (ADR 0024). Es la forma de escribirle a un
contacto con la ventana de 24 horas cerrada o que nunca escribió: esta
ruta **no** evalúa la ventana.

Destino, `Idempotency-Key` (obligatoria), controles de cuenta y forma
de la respuesta son los mismos de `POST /api/meta/whatsapp/send`.

`template` usa el vocabulario de Cloud API: `name`, `language` (código
como `es_MX` o `en_US`) y `components` opcional, que se reenvía tal cual
a Meta (Resender solo valida que sea un array). Si la copia local del
catálogo sabe que la plantilla no está `APPROVED`, responde `409
template_not_approved` sin llamar a Meta; si no la conoce, envía y
decide Meta.

Disponible también en el plan Free. Consume 1 de cuota solo si Meta
aceptó. Meta factura las plantillas directo al método de pago de la
WABA, no a través de Resender.

## 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`. |
| `template` | object | Sí |  |
| `template.name` | string | Sí | Nombre exacto de la plantilla en WhatsApp Manager. (minLength: 1) |
| `template.language` | string | Sí | Código de idioma de la plantilla. (minLength: 1) |
| `template.components` | object[] | No | Componentes con los parámetros (`header`, `body`, `button`), en el formato de Cloud API. Se reenvían tal cual a Meta; un valor por cada variable `{{n}}`, en orden. |

Al menos una de estas variantes:

**Variante 1**

Requiere: `conversationId`

**Variante 2**

Requiere: `pageId`, `recipientId`

### Ejemplos

Ejemplo `sinVariables` — Plantilla sin variables:

```json
{
  "pageId": "109876543210987",
  "recipientId": "+52 55 1234 5678",
  "template": {
    "name": "hello_world",
    "language": "en_US"
  }
}
```

Ejemplo `conVariables` — Plantilla con variables en el cuerpo:

```json
{
  "conversationId": "6f0e5a2c-8a5e-4a3d-9c2b-1f2e3d4c5b6a",
  "template": {
    "name": "pedido_enviado",
    "language": "es_MX",
    "components": [
      {
        "type": "body",
        "parameters": [
          {
            "type": "text",
            "text": "Ana"
          },
          {
            "type": "text",
            "text": "#8841"
          }
        ]
      }
    ]
  }
}
```

Ejemplo `conEncabezado` — Plantilla con imagen en el encabezado:

```json
{
  "pageId": "109876543210987",
  "recipientId": "5215512345678",
  "template": {
    "name": "promo_temporada",
    "language": "es_MX",
    "components": [
      {
        "type": "header",
        "parameters": [
          {
            "type": "image",
            "image": {
              "link": "https://cdn.example.com/promo.jpg"
            }
          }
        ]
      },
      {
        "type": "body",
        "parameters": [
          {
            "type": "text",
            "text": "20%"
          }
        ]
      }
    ]
  }
}
```

## Ejemplo de petición

```bash
curl -X POST "https://resender.dev/api/meta/whatsapp/templates/send" \
  -H "Authorization: Bearer <token>" \
  -H "Idempotency-Key: <Idempotency-Key>" \
  -H "Content-Type: application/json" \
  -d '{"pageId":"109876543210987","recipientId":"+52 55 1234 5678","template":{"name":"hello_world","language":"en_US"}}'
```

## Respuestas

### 200

Meta aceptó la plantilla (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==",
        "message_status": "accepted"
      }
    ]
  },
  "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, plantilla incompleta 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"` \| `"conversation_channel_mismatch"` \| `"invalid_recipient"` \| `"template_missing"` \| `"template_name_missing"` \| `"template_language_missing"` \| `"template_components_invalid"` | 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 `plantilla`:

```json
{
  "code": "template_missing",
  "error": "missing template: send { name, language, components? }"
}
```

Ejemplo `idioma`:

```json
{
  "code": "template_language_missing",
  "error": "missing template.language (for example en_US)"
}
```

Ejemplo `componentes`:

```json
{
  "code": "template_components_invalid",
  "error": "template.components must be an array"
}
```

### 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 plantilla existe pero no está `APPROVED`. No se llamó a Meta ni se consumió cuota.

`application/json`

| Campo | Tipo | Requerido | Descripción |
| --- | --- | --- | --- |
| `code` | `"template_not_approved"` | Sí |  |
| `error` | string | Sí |  |
| `templateStatus` | string | Sí | Estado de la plantilla en WhatsApp, por ejemplo `PENDING`, `REJECTED`, `PAUSED` o `DISABLED`. |

```json
{
  "code": "template_not_approved",
  "error": "The template \"pedido_enviado\" (es_MX) is PENDING in WhatsApp, not APPROVED, so it can't be sent yet.",
  "templateStatus": "PENDING"
}
```

### 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"
  }
}
```