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:

bash
curl https://api.whatsup.avanc3.pe/v1/ping \
  -H "X-API-Key: wsp_TU_CLAVE"
json
{
  "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.

json
{
  "error": {
    "codigo": "sin_conversacion",
    "mensaje": "Ese número no tiene ninguna conversación abierta…"
  }
}
CódigoQué pasó
sin_claveNo mandaste ninguna clave.
clave_invalidaLa clave no existe o fue revocada.
no_encontradaEsa conversación no existe, o no es de tu cuenta.
sin_conversacionEse número no te ha escrito nunca.
demasiadas_peticionesTe 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.

bash
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.

json
{
  "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.

bash
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.

EventoCuándo se manda
mensaje.recibidoUn cliente escribe.
mensaje.enviadoSale un mensaje desde Whatsup.
mensaje.estadoUn mensaje se entrega, se lee o falla.
conversacion.asignadaUna conversación pasa a un agente.
conversacion.resueltaUna conversación se da por cerrada.

Esto es lo que recibe tu servidor:

http
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 entregaId se 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.

node
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
<?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);
python
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