# Evento de WhatsApp (/api/whatsappEvents)

**Webhook** `POST` a la URL configurada en la conexión (evento `whatsappEvents`)

Mismo endpoint, cabeceras y política de reintentos que Messenger, con
`page.channel: whatsapp` y los campos propios del canal en `page` y en
`message`.

**Adjuntos.** Una imagen, audio, vídeo, documento o sticker se guarda
en Resender y el evento **sale cuando el archivo ya está disponible**:
llega una sola vez, con `details.status: available` y en
`attachment.url` un link firmado que se abre sin cabeceras durante
24 h (`details.urlExpiresAt`). Ver `downloadWhatsappMedia`.
- Si el archivo no se pudo bajar de WhatsApp, o no estuvo listo en
  2 minutos, el evento sale igual con `details.status: failed` y
  `url: null`. El texto y el resto del mensaje llegan completos.
- `unavailable` (WhatsApp no ofreció el archivo) y `deleted` también
  llegan con `url: null`.
- Ubicación, contactos, reacción, respuesta interactiva, pedido y
  evento de sistema no tienen archivo: `url: null` y sin
  `details.status`.
- El pie de foto es `message.text`.

**Orden.** Los mensajes de una misma conversación llegan en el orden
en que se escribieron. Uno no sale mientras el anterior esté
esperando su archivo o reintentándose. Un mensaje que Meta nos
entrega tarde, con hora anterior a uno ya enviado, sale apenas llega:
ordená por `message.createdAt` si necesitás el orden exacto.

**Ecos.** En Coexistence, lo que el negocio escribe desde la app de
WhatsApp Business llega con `direction: outbound` y
`origin: business_app`. Filtralos para que tu bot no se responda a sí
mismo. El historial importado al conectar no se reenvía.

## Autenticación

- **WebhookSignature**: header `resender-signature`. Presente solo si la conexión tiene secreto de firma (`whsec_…`). `v1=` + HMAC-SHA256 en hex de `"<resender-event-id>.<resender-timestamp>.<cuerpo crudo>"`. Verificá sobre el cuerpo crudo, en tiempo constante y con una tolerancia de ±300 s sobre `resender-timestamp`.

- También puede llegar sin autenticación.

## Parámetros

| Nombre | En | Tipo | Requerido | Descripción |
| --- | --- | --- | --- | --- |
| `resender-event-id` | header | string | No | Id determinista del evento. El mismo evento reingerido trae el mismo id; usalo para deduplicar. (pattern: `^evt_[0-9a-f]{32}$`) |
| `resender-timestamp` | header | string | No | Epoch en segundos del momento de la firma. (pattern: `^[0-9]+$`) |
| `resender-signature` | header | string | No | `v1=` + HMAC-SHA256 hex. Ver `WebhookSignature`. (pattern: `^v1=[0-9a-f]{64}$`) |

## Cuerpo del evento

`application/json` (requerido)

| Campo | Tipo | Requerido | Descripción |
| --- | --- | --- | --- |
| `type` | `"message"` | Sí |  |
| `tenant` | object | Sí |  |
| `tenant.id` | string (uuid) | Sí |  |
| `page` | object | Sí |  |
| `page.id` | string (uuid) | Sí | Id de la conexión en Resender. |
| `page.channel` | `"messenger"` \| `"instagram"` \| `"whatsapp"` | Sí |  |
| `page.metaPageId` | string | Sí | Page id en Messenger; IG user id de la cuenta profesional en Instagram; `phone_number_id` en WhatsApp. Es el `pageId` de los endpoints de envío. |
| `page.name` | string | Sí |  |
| `page.username` | string \| null | Sí | @handle. Solo Instagram; `null` en Messenger y WhatsApp. |
| `page.phoneNumberId` | string | No | Solo WhatsApp. El mismo valor que `metaPageId`. |
| `page.wabaId` | string \| null | No | Solo WhatsApp. Cuenta de WhatsApp Business del número. |
| `page.onboardingMode` | `"standard"` \| `"coexistence"` \| null | No | Solo WhatsApp. `coexistence` si el número también se usa desde la app de WhatsApp Business. |
| `conversation` | object | Sí |  |
| `conversation.id` | string (uuid) | Sí | Usable como `conversationId` en los endpoints de envío. |
| `conversation.contactId` | string | Sí | PSID (Messenger), IGSID (Instagram) o `wa_id` (WhatsApp) del contacto. Es el `recipientId` de los endpoints de envío. |
| `message` | object | Sí |  |
| `message.id` | string (uuid) | Sí |  |
| `message.metaMessageId` | string \| null | Sí |  |
| `message.eventType` | `"message"` \| `"postback"` | Sí |  |
| `message.postbackPayload` | string \| null | Sí | Payload del botón cuando `eventType` es `postback`. |
| `message.direction` | `"inbound"` \| `"outbound"` | Sí |  |
| `message.status` | `"received"` \| `"sent"` \| `"failed"` | Sí |  |
| `message.text` | string | Sí | `""` cuando el mensaje no trajo texto. |
| `message.attachment` | object \| null | Sí | Siempre presente; `null` cuando no hubo adjunto. En Instagram es siempre `null` por ahora. |
| `message.createdAt` | string (date-time) | Sí | Hora del mensaje según Meta. Es el orden de la conversación. |
| `message.origin` | `"customer"` \| `"resender_api"` \| `"business_app"` \| `"history"` \| `"system"` | No | Solo WhatsApp. Quién produjo el mensaje; `business_app` es un eco escrito desde la app de WhatsApp Business. |
| `message.historical` | boolean | No | Solo WhatsApp. Siempre `false` en el webhook (el historial no se reenvía). |
| `message.replyToProviderMessageId` | string \| null | No | Solo WhatsApp. `wamid` del mensaje al que responde. |
| `message.deliveryStatus` | `"accepted"` \| `"sent"` \| `"delivered"` \| `"read"` \| `"failed"` \| `"deleted"` \| null | No | Solo WhatsApp. Estado que reportó Meta para un saliente. |

### Ejemplos

Ejemplo `imagen` — Imagen recibida:

```json
{
  "type": "message",
  "tenant": {
    "id": "299ae9fd-0f88-41c0-9be6-6a77af820f0b"
  },
  "page": {
    "id": "60c64f0e-00f7-4a05-8ace-ebf55d32de23",
    "channel": "whatsapp",
    "metaPageId": "1323965804140265",
    "name": "+5215639549493",
    "username": null,
    "phoneNumberId": "1323965804140265",
    "wabaId": "2186711465527958",
    "onboardingMode": "coexistence"
  },
  "conversation": {
    "id": "fbfdf7d2-6c92-4766-893a-459d31b256ba",
    "contactId": "5215533397755"
  },
  "message": {
    "id": "cce03202-3f6d-4379-b85e-6cc11063ccd5",
    "metaMessageId": "wamid.HBgNNTIxNTUzMzM5Nzc1NRUCABIYFDNBMzg3OTU3NDUyMjc5QTZCNzJDAA==",
    "eventType": "message",
    "postbackPayload": null,
    "direction": "inbound",
    "status": "received",
    "text": "",
    "attachment": {
      "type": "image",
      "url": "https://resender.dev/api/meta/whatsapp/media/cce03202-3f6d-4379-b85e-6cc11063ccd5?exp=1792526083&sig=1pQ2o3r4S5t6U7v8W9x0Y1z2A3b4C5d6E7f8G9h0I1j",
      "title": null,
      "details": {
        "status": "available",
        "mimeType": "image/jpeg",
        "sizeBytes": 32671,
        "sha256": "3tMN9VDdwm57MjYO++WdT7Bka/1erTllGtiwLOmoaWs=",
        "providerMediaId": "1093987910041490",
        "urlExpiresAt": "2026-10-20T19:54:43.000Z"
      }
    },
    "createdAt": "2026-10-19T19:54:43.000Z",
    "origin": "customer",
    "historical": false,
    "replyToProviderMessageId": null,
    "deliveryStatus": null
  }
}
```

Ejemplo `adjuntoFallido` — Adjunto que no se pudo bajar:

```json
{
  "type": "message",
  "tenant": {
    "id": "299ae9fd-0f88-41c0-9be6-6a77af820f0b"
  },
  "page": {
    "id": "60c64f0e-00f7-4a05-8ace-ebf55d32de23",
    "channel": "whatsapp",
    "metaPageId": "1323965804140265",
    "name": "+5215639549493",
    "username": null,
    "phoneNumberId": "1323965804140265",
    "wabaId": "2186711465527958",
    "onboardingMode": "coexistence"
  },
  "conversation": {
    "id": "fbfdf7d2-6c92-4766-893a-459d31b256ba",
    "contactId": "5215533397755"
  },
  "message": {
    "id": "3d1e7a90-5b2c-4f8e-9a6d-1c0b2e3f4a5b",
    "metaMessageId": "wamid.HBgNNTIxNTUzMzM5Nzc1NRUCABIYFDNBMzg3OTU3NDUyMjc5QTZCNzJEAA==",
    "eventType": "message",
    "postbackPayload": null,
    "direction": "inbound",
    "status": "received",
    "text": "Te mando la factura",
    "attachment": {
      "type": "file",
      "url": null,
      "title": null,
      "details": {
        "status": "failed",
        "failureReason": "media_download_timeout",
        "mimeType": "application/pdf",
        "filename": "factura.pdf",
        "providerMediaId": "1093987910041491"
      }
    },
    "createdAt": "2026-10-19T19:55:10.000Z",
    "origin": "customer",
    "historical": false,
    "replyToProviderMessageId": null,
    "deliveryStatus": null
  }
}
```

## Respuestas esperadas de tu webhook

### 408

Se reintenta.

### 429

Se reintenta.

### 2XX

Entrega aceptada. El cuerpo se ignora.

### 4XX

Fallo permanente. No se reintenta.

### 5XX

Se reintenta.