Cómo saber si podés mandar texto libre: serviceWindowExpiresAt en la API de Waspy
serviceWindowExpiresAt viene en GET /conversations/:id y en el listado. resolve y by-phone no lo traen. Si está vacío o vencido, un texto responde 403 SERVICE_WINDOW_EXPIRED.
Respuesta corta: antes de un texto libre, leé serviceWindowExpiresAt. Está en GET https://api.waspytech.com/api/v2/conversations/:id y en el listado GET /api/v2/conversations. Si la fecha es null o ya pasó, POST /messages con type: "text" responde 403 SERVICE_WINDOW_EXPIRED. Ahí el pedido que sale es una plantilla.
Lo confirmé contra el código el 1 de noviembre de 2026. GET /conversations/resolve y GET /conversations/by-phone/:phone siguen sin traer ese campo. La nota del 31 de octubre decía lo mismo: no cambió. Cómo se busca el teléfono está en resolver la conversación.
Dónde está, y dónde no
| Pedido | ¿Trae serviceWindowExpiresAt? |
|---|---|
GET /conversations/:id | Sí. ISO-8601 o null |
GET /conversations | Sí, en cada ítem |
GET /contacts/:id/conversations | Sí. Scope contacts:read |
GET /conversations/by-phone/:phone | No. conversationId, contactName, contactPhone |
GET /conversations/resolve | No. conversationId, match, alternatives, phoneHasWhatsApp |
GET /contacts/:id/can-send-text | No existe |
by-phone. El schema ConversationByPhoneResponse no tiene esa propiedad. Si tu cliente fue generado contra esa frase, va a leer undefined y va a mandar el texto igual.
No hay un booleano canSendText. La regla la aplicás vos con la fecha, y el servidor la vuelve a aplicar al enviar.
El GET
curl -sS "https://api.waspytech.com/api/v2/conversations/UUID-DEL-CHAT" \
-H "Authorization: Bearer $WASPY_API_KEY"
{
"data": {
"id": "UUID-DEL-CHAT",
"contactId": "UUID-DEL-CONTACTO",
"phoneNumberId": "UUID-DEL-CANAL",
"status": "open",
"serviceWindowExpiresAt": "2026-11-02T18:40:00.000Z",
"contact": { "name": "María Pérez", "phoneNumber": "+5491126032641", "email": null }
},
"meta": { "requestId": "..." }
}
| Valor | Qué podés mandar |
|---|---|
| Un ISO en el futuro | Texto, foto, audio, documento, interactivo, reacción. También una plantilla |
null | Solo plantilla. El envío trata el vacío como ventana cerrada |
| Un ISO que ya pasó | Solo plantilla |
El 403
{
"error": {
"code": "SERVICE_WINDOW_EXPIRED",
"message": "Service window expired. Only templates can be sent outside the 24h window",
"status": 403
},
"meta": { "requestId": "..." }
}
Ese código lo lanza el envío cuando el tipo no es template y la fecha falta o venció. Un type: "template" no entra en ese if. Meta igual puede rechazar la plantilla por otra causa (nombre, idioma, parámetros): eso llega después, en message.status_changed, no como este 403.
Texto o plantilla
| Situación | Pedido | Qué cobra Meta, Argentina, desde el 1 de octubre de 2026 |
|---|---|---|
| La fecha es futura y el cliente preguntó “¿salió?” | type: "text" | Servicio: 1.000 por número y por mes; después ~ARS 37,68 (~US$0,026) |
La fecha es null o venció, y hay que avisar el despacho | type: "template", utilidad, sin cupón | ~ARS 37,68 (~US$0,026) por mensaje entregado |
| El chat está abierto, pero reenviás la plantilla | type: "template" | Se cobra igual. Desde el 1 de octubre la utilidad dentro de la ventana no es gratis |
| El texto lleva un “15% en la próxima” y sale como plantilla | Marketing | ~ARS 89,56 (~US$0,0618) |
Si la persona escribió desde un anuncio de clic a WhatsApp, hay un segundo reloj (hasta 7 días en la documentación de desarrolladores; la página comercial, leída el 1 de noviembre de 2026, sigue en 72 horas). Ese reloj no alarga esta fecha. Pasadas las 24 horas el texto sigue en 403, aunque el punto de entrada siga abierto: lo que sale es la plantilla. El cruce está en la ventana de 7 días.
Node
const base = 'https://api.waspytech.com/api/v2';
const headers = { Authorization: 'Bearer ' + process.env.WASPY_API_KEY };
async function canSendText(conversationId) {
const res = await fetch(base + '/conversations/' + conversationId, { headers });
if (!res.ok) throw new Error('GET conversation ' + res.status);
const body = await res.json();
const exp = body.data && body.data.serviceWindowExpiresAt;
return Boolean(exp) && new Date(exp).getTime() > Date.now();
}
async function reply(conversationId, phoneNumberId, to, text, template) {
const open = await canSendText(conversationId);
const payload = open
? { phoneNumberId, to, type: 'text', text: { body: text } }
: { phoneNumberId, to, type: 'template', template };
const res = await fetch(base + '/messages', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
return res.json();
}
Python
import os
from datetime import datetime, timezone
import requests
BASE = "https://api.waspytech.com/api/v2"
HEADERS = {"Authorization": "Bearer " + os.environ["WASPY_API_KEY"]}
def can_send_text(conversation_id: str) -> bool:
res = requests.get(f"{BASE}/conversations/{conversation_id}", headers=HEADERS, timeout=20)
res.raise_for_status()
exp = res.json()["data"].get("serviceWindowExpiresAt")
if not exp:
return False
return datetime.fromisoformat(exp.replace("Z", "+00:00")) > datetime.now(timezone.utc)
El GET previo y el POST no son atómicos: la ventana puede vencer en el medio. Si el POST vuelve 403, reintentá con la plantilla. No reintentes el texto.
La API pública está en Scale y Enterprise. El inbox, donde una persona ve el contador sin este GET, está desde el plan de entrada. Los dos están en Planes y en la API. Diez días sin tarjeta, en el registro.
