API de Whatsup
Para que tu tienda, tu ERP o tu automatización manden mensajes de WhatsApp y se enteren de los que llegan, sin que nadie tenga que mirar una pantalla.
Empezar
Un administrador de la cuenta crea la clave en Ajustes > Integraciones. Se enseña una sola vez: de la clave guardamos una huella, no la clave, así que nadie puede recuperarla después. Si se pierde, se crea otra y se revoca la vieja.
La dirección base es https://api.whatsup.avanc3.pe/v1 y la clave va en la cabecera X-API-Key. También se admite Authorization: Bearer.
Lo primero que conviene probar, porque no cambia nada:
curl https://api.whatsup.avanc3.pe/v1/ping \ -H "X-API-Key: wsp_TU_CLAVE"
{
"ok": true,
"empresa": { "id": "145fd56f-…", "nombre": "Andina Coffee Roasters" },
"momento": "2026-09-02T09:46:27.649Z"
}La clave abre la cuenta entera. Guárdala como guardarías una contraseña: en las variables de entorno de tu servidor, nunca en el código del navegador ni en un repositorio.
Errores y cupo
Todos los errores llegan con la misma forma. Compara el codigo, que es estable; el mensaje está para leerlo y puede cambiar de redacción.
{
"error": {
"codigo": "sin_conversacion",
"mensaje": "Ese número no tiene ninguna conversación abierta…"
}
}| Código | Qué pasó |
|---|---|
| sin_clave | No mandaste ninguna clave. |
| clave_invalida | La clave no existe o fue revocada. |
| no_encontrada | Esa conversación no existe, o no es de tu cuenta. |
| sin_conversacion | Ese número no te ha escrito nunca. |
| demasiadas_peticiones | Te pasaste del cupo. Espera y reintenta. |
El cupo es de 120 peticiones por minuto y clave, y 300 por minuto y dirección de origen. Cada respuesta trae RateLimit-Remaining, así que puedes frenar tú antes de que te frenemos nosotros. Si necesitas más, escríbenos.
Endpoints
Todas las listas se paginan con limite (hasta 100, por defecto 25) y desde. Para la página siguiente, súmale el límite a desde.
GET/v1/ping
Comprueba la clave y dice de qué empresa es. No cambia nada.
GET/v1/conversaciones
De la más reciente a la más antigua. Admite estado, que puede ser open, bot (la atiende el agente de IA), pending (espera a una persona) o resolved.
curl "https://api.whatsup.avanc3.pe/v1/conversaciones?estado=pending&limite=50" \ -H "X-API-Key: wsp_TU_CLAVE"
GET/v1/conversaciones/{id}/mensajes
Del más antiguo al más reciente, que es como se lee una conversación.
{
"datos": [
{
"id": "8978f13e-…",
"sentido": "entrante",
"tipo": "text",
"contenido": "¿Tienen café descafeinado?",
"estado": "delivered",
"creadoEn": "2026-09-02T09:03:56.859Z"
}
],
"paginacion": { "limite": 25, "desde": 0 }
}GET/v1/clientes
Los que han escrito alguna vez. Admite telefono para buscar uno concreto; los símbolos y espacios se ignoran.
POST/v1/mensajes
Manda un texto a una conversación que ya existe.
curl -X POST https://api.whatsup.avanc3.pe/v1/mensajes \
-H "X-API-Key: wsp_TU_CLAVE" \
-H "content-type: application/json" \
-d '{"telefono":"51917919061","texto":"Tu pedido ya salió"}'En vez de telefono puedes mandar conversacionId si ya lo tienes.
No se puede empezar una conversación desde aquí. No es limitación nuestra: WhatsApp solo deja escribir texto libre a quien te escribió en las últimas 24 horas. Para dirigirte a alguien que no ha escrito hace falta una plantilla aprobada, y eso se hace con las campañas. Si lo intentas, te devolvemos sin_conversacion.
El mensaje no se atribuye a nadie del equipo: lo manda un programa, y ponerle el nombre de una persona falsearía quién contestó.
Recibir avisos
En lugar de preguntar cada pocos segundos, te avisamos. Se configura en Ajustes > Integraciones: pones la dirección de tu sistema, marcas qué te interesa y copias el secreto de firma. La dirección tiene que ser https y pública.
| Evento | Cuándo se manda |
|---|---|
| mensaje.recibido | Un cliente escribe. |
| mensaje.enviado | Sale un mensaje desde Whatsup. |
| mensaje.estado | Un mensaje se entrega, se lee o falla. |
| conversacion.asignada | Una conversación pasa a un agente. |
| conversacion.resuelta | Una conversación se da por cerrada. |
Esto es lo que recibe tu servidor:
POST /tu-endpoint HTTP/1.1
content-type: application/json
x-whatsup-evento: mensaje.recibido
x-whatsup-entrega: 9f1c3b60-2f4a-4d1e-9a77-1b0c5e2d8a34
x-whatsup-firma: t=1788340840,v1=6f2a…
{
"evento": "mensaje.recibido",
"entregaId": "9f1c3b60-2f4a-4d1e-9a77-1b0c5e2d8a34",
"emitidoEn": "2026-09-02T09:46:27.649Z",
"datos": {
"conversacionId": "0f2f7c99-…",
"mensajeId": "8978f13e-…",
"cliente": { "telefono": "51917919061", "nombre": "Kevin Soto" },
"tipo": "text",
"contenido": "¿Tienen café descafeinado?"
}
}- Contesta rápido con un 2xx. Cortamos a los diez segundos. Si tienes trabajo que hacer, encólalo y contesta antes.
- El mismo aviso puede llegarte dos veces. Reintentamos cuando fallas, y
entregaIdse repite en los reintentos: guárdalo y descarta lo que ya procesaste. - Si fallas quince veces seguidas lo apagamos y dejamos escrito el último error en la pantalla de Integraciones. Se vuelve a encender marcándolo como activo.
- No seguimos redirecciones. Danos la dirección final.
Comprobar la firma
Sin esto, cualquiera que adivine tu dirección puede inventarte mensajes entrantes. La cabecera x-whatsup-firma viene como t=<segundos>,v1=<hmac>, donde el hmac es SHA-256 de <t>.<cuerpo crudo> con el secreto de esa dirección.
Dos detalles que se escapan y dejan el hueco abierto: hay que firmar sobre el cuerpo crudo, no sobre el JSON vuelto a serializar, porque un espacio de diferencia cambia el resultado. Y hay que rechazar lo que venga con una t vieja, o un aviso capturado se puede reenviar meses después.
import crypto from 'crypto'
import express from 'express'
const app = express()
const SECRETO = process.env.WHATSUP_SECRETO
// El cuerpo crudo, sin parsear: la firma se hizo sobre esos bytes exactos.
app.post('/whatsup', express.raw({ type: 'application/json' }), (req, res) => {
const cabecera = req.get('x-whatsup-firma') || ''
const partes = Object.fromEntries(cabecera.split(',').map(p => p.split('=')))
const cuerpo = req.body.toString('utf8')
const esperado = crypto
.createHmac('sha256', SECRETO)
.update(`${partes.t}.${cuerpo}`)
.digest('hex')
const iguales =
partes.v1 &&
partes.v1.length === esperado.length &&
crypto.timingSafeEqual(Buffer.from(esperado), Buffer.from(partes.v1))
// Cinco minutos de margen para el reloj y para los reintentos.
const reciente = Math.abs(Date.now() / 1000 - Number(partes.t)) < 300
if (!iguales || !reciente) return res.sendStatus(401)
const aviso = JSON.parse(cuerpo)
// Contesta ya y haz el trabajo después: cortamos a los diez segundos.
res.sendStatus(200)
encolar(aviso)
})<?php
$secreto = getenv('WHATSUP_SECRETO');
$cuerpo = file_get_contents('php://input');
parse_str(str_replace(',', '&', $_SERVER['HTTP_X_WHATSUP_FIRMA'] ?? ''), $partes);
$esperado = hash_hmac('sha256', $partes['t'] . '.' . $cuerpo, $secreto);
if (!hash_equals($esperado, $partes['v1'] ?? '') || abs(time() - (int) $partes['t']) > 300) {
http_response_code(401);
exit;
}
http_response_code(200);
$aviso = json_decode($cuerpo, true);import hmac, hashlib, os, time
from flask import Flask, request, abort
app = Flask(__name__)
SECRETO = os.environ["WHATSUP_SECRETO"].encode()
@app.post("/whatsup")
def whatsup():
cabecera = request.headers.get("x-whatsup-firma", "")
partes = dict(p.split("=", 1) for p in cabecera.split(",") if "=" in p)
cuerpo = request.get_data() # crudo, sin parsear
esperado = hmac.new(
SECRETO, f"{partes.get('t')}.".encode() + cuerpo, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(esperado, partes.get("v1", "")):
abort(401)
if abs(time.time() - int(partes["t"])) > 300:
abort(401)
encolar(request.get_json())
return "", 200