clientRef: reconciliar los estados de WhatsApp con tu ERP
clientRef es un texto de hasta 256 caracteres. Lo mandás en POST /messages y vuelve en message.status_changed y en GET /messages/:id. Al encolar, waMessageId es null.
Respuesta corta: mandá clientRef (máximo 256 caracteres) en POST https://api.waspytech.com/api/v2/messages. Waspy lo devuelve igual en la respuesta, en GET /messages/:id y en cada webhook message.status_changed. Con eso tu ERP marca el pedido como entregado o leído sin guardar una tabla de ids. En la respuesta inicial, status es queued y waMessageId es null: el id de Meta aparece recién cuando el mensaje sale.
El lote de hasta 100 está en mensajes en lote. La clave que evita un doble envío es otra cosa: idempotencia y scopes. El payload del webhook está en eventos.
Qué problema resuelve
Tiendanube, o el ERP, tiene el pedido 1847. WhatsApp tiene un wamid que todavía no existe cuando encolás. Si guardás solo el UUID de Waspy, cada estado te obliga a buscar. Si mandás clientRef: "tn_1847_pago", el webhook trae ese texto y el estado nuevo. Actualizás la fila del pedido y listo.
phoneNumberId en el POST es el UUID del canal en Waspy, el id de GET /channels. No es el phoneNumberId de Meta. Ese otro campo también viene en el canal, con otro nombre.
Del pedido al webhook
- El pedido queda pagado en tu sistema.
POST /messagescon la plantilla yclientRef.- La API responde 201.
data.statusesqueued,data.waMessageIdesnull,data.clientRefes el tuyo,data.ides el UUID de Waspy. - Llega
message.status_changedconnewStatus: "sent"y, ahí sí,waMessageId. - Después
delivered,readofailed. Siempre con el mismoclientRef.
curl -sS -X POST https://api.waspytech.com/api/v2/messages \
-H "Authorization: Bearer $WASPY_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: tn-1847-pago" \
-d '{
"phoneNumberId": "UUID-DEL-CANAL",
"to": "+5491155554444",
"type": "template",
"clientRef": "tn_1847_pago",
"template": {
"name": "confirmacion_pago",
"language": { "code": "es_AR" },
"components": [
{
"type": "body",
"parameters": [
{ "type": "text", "text": "1847" }
]
}
]
}
}'
{
"data": {
"id": "msg-uuid",
"conversationId": "conv-uuid",
"status": "queued",
"waMessageId": null,
"clientRef": "tn_1847_pago",
"type": "template"
},
"meta": { "requestId": "..." }
}
El scope del POST es messages:write. La plantilla tiene que estar approved. Cómo listarla está en listar plantillas.
El webhook real
Cuando Meta acepta el envío, el cuerpo que llega a tu URL tiene esta forma (el sobre es el de eventos):
{
"event": "message.status_changed",
"webhookId": "uuid-de-tu-suscripcion",
"deliveredAt": "2026-10-24T14:02:01.000Z",
"attempt": 1,
"data": {
"id": "msg-uuid",
"conversationId": "conv-uuid",
"contactId": "uuid-contacto",
"phoneNumberId": "uuid-canal",
"direction": "outbound",
"type": "template",
"status": "delivered",
"waMessageId": "wamid.HBgLNT...",
"errorCode": null,
"errorMessage": null,
"origin": "external_api",
"clientRef": "tn_1847_pago",
"previousStatus": "sent",
"newStatus": "delivered"
}
}
| Campo | Para el ERP |
|---|---|
data.clientRef | Tu clave. Si no la mandaste, null |
data.newStatus | sent, delivered, read o failed |
data.previousStatus | El estado anterior de este evento |
data.id | UUID de Waspy, por si también guardaste el 201 |
data.waMessageId | El wamid de Meta. En queued todavía no está |
data.errorCode | Si newStatus es failed. Ejemplo de Meta: 131026, 131049 |
Los eventos pueden llegar fuera de orden. Por cada clientRef guardá el estado más avanzado (read > delivered > sent) y tratá failed como terminal: un delivered tardío no revive un fallo. Si el mensaje no se entregó, Meta no lo cobra.
Si no querés un webhook
GET /api/v2/messages/:id con scope messages:read devuelve el mismo mensaje, con clientRef, status y waMessageId. El :id es el UUID del 201, no el wamid.
Polling de muchos pedidos se come el cupo de 120 pedidos por minuto por cuenta. El webhook es el camino. El GET sirve para un pedido puntual o para recuperar un waMessageId si se te pasó el evento sent.
El lote también lo devuelve
En POST /messages/batch cada ítem puede traer su clientRef. La respuesta es 202. Los que se encolan vuelven result: "queued" con el mensaje adentro, waMessageId: null y el clientRef repetido en el ítem.
{
"index": 0,
"clientRef": "tn_1847_pago",
"result": "queued",
"message": {
"id": "msg-uuid",
"status": "queued",
"waMessageId": null,
"clientRef": "tn_1847_pago"
}
}
Un ítem skipped (opt-out) o failed (teléfono inválido, ventana cerrada) no tiene mensaje en camino: no esperes un delivered para ese clientRef. El header Idempotency-Key no aplica al lote. Si reintentás la tanda, los que ya estaban queued se encolan otra vez, con el mismo clientRef. Persistí el resultado antes de reintentar. El detalle del 202 está en el lote.
clientRef no es la clave de idempotencia
clientRef | Idempotency-Key | |
|---|---|---|
| Dónde | Body, en el mensaje y en cada ítem del lote | Header, solo en POST /messages |
| Para qué | Que el estado vuelva con tu id | Que un reintento del mismo POST no mande dos veces |
| Tope | 256 caracteres | 256 caracteres, y dura 24 horas |
| En el webhook | Sí, en message.status_changed | No |
clientRef reconcilia. Un mismo clientRef en dos POST distintos son dos mensajes.
Qué cobra Meta cuando el estado es delivered
El delivered es el momento en que Meta puede facturar, según la categoría de la plantilla y el país del destinatario. En Argentina, desde el 1 de octubre de 2026:
| Categoría | Dólares | Pesos |
|---|---|---|
| Marketing | US$0,0618 | ARS 89,56 |
| Utilidad y autenticación | US$0,026 | ARS 37,68 |
| Servicio, dentro de la ventana | 1.000 por número y por mes; después US$0,026 | después ARS 37,68 |
failed no entra en esa cuenta. El desglose está en cuánto cuesta la API.
Si el aviso es texto libre y la ventana de 24 horas está cerrada, el POST ni siquiera encola: responde 403 SERVICE_WINDOW_EXPIRED. Ahí no hay webhook de estado. La regla está en la ventana de 24 horas.
La referencia del campo está en messages. La API está en Scale y Enterprise: funcionalidades.
