Introducción
La API de CeroManual es la superficie programática del servicio: envía mensajes de WhatsApp, transfiere archivos y recibe eventos (mensajes entrantes y confirmaciones de entrega) en tu propio webhook.
https://app.ceromanual.com
Todas las peticiones y respuestas son JSON, salvo las transferencias de archivos, que son bytes crudos. Las respuestas de error siempre tienen la forma {"error": "…"}; los códigos exactos por endpoint están en Errores.
La especificación legible por máquina vive en /openapi.json (OpenAPI 3.1): impórtala en Postman o Insomnia, o genera un cliente con ella.
Empieza en cinco minutos
Tres pasos para enviar tu primer mensaje. Solo necesitas un número conectado en la consola.
1 · Crea una clave API
En la consola, abre el panel de tu número → Claves API → Crear clave API. Copia el secreto cmk_… en ese momento: no se vuelve a mostrar.
2 · Envía un mensaje de prueba
El destinatario debe haberte escrito en las últimas 24 horas (es la ventana de 24 h de WhatsApp). Sustituye la clave y el teléfono:
curl https://app.ceromanual.com/api/send \
-H "Authorization: Bearer cmk_TU_CLAVE" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"to": "5215512345678", "type": "text", "text": {"body": "Hola desde la API"}}'
La respuesta trae "status": "succeeded" y un messageId: Meta aceptó el mensaje.
3 · Recibe eventos en tu servidor
Guarda la URL HTTPS de tu servidor en Webhook de entrega — eso es todo. Al guardarla enviamos un POST de prueba y tu servidor solo tiene que responder 2xx; no hay ningún reto que devolver, así que un disparador de n8n, Make, Zapier o cualquier servidor funciona sin tocar nada. Desde entonces, cada mensaje entrante y cada confirmación llegan como un POST firmado — la confirmación de tu envío se ve así:
{
"version": 1,
"delivery_id": "dlv_3c1f9a",
"event_type": "status",
"occurred_at": "2026-07-20T10:32:04.000Z",
"data": { "message_id": "wamid.HBgL...", "status": "delivered" }
}
Eso es todo. El contrato completo empieza en Autenticación.
Autenticación
La API se autentica con claves por número: cada clave pertenece a exactamente un número de WhatsApp conectado. Una clave de un número nunca puede enviar, subir ni descargar en nombre de otro.
Las claves se crean y revocan en la consola (panel de cada número → Claves API). El secreto (cmk_…) se muestra exactamente una vez al crearla; solo almacenamos un hash. La revocación surte efecto de inmediato.
Envíala en cada llamada:
Authorization: Bearer cmk_...
- Las cookies de sesión de la consola nunca se aceptan en la API, y las claves API nunca se aceptan en la consola.
401 invalid-api-keysignifica: cabecera ausente o malformada, clave desconocida o revocada, o número desconectado.- Las claves solo funcionan mientras el número está
conectado. Un número que requiere reautorización o está pausado por facturación rechaza llamadas con409 wrong-statehasta que lo resuelvas en la consola.403 needs-reauthaparece cuando Meta rechaza las credenciales guardadas en plena llamada — reconecta el número desde la consola.
Idempotencia
POST /api/send exige la cabecera Idempotency-Key (cualquier texto de hasta 255 caracteres; usa un UUID por mensaje). Exactamente una petición por combinación de número y clave llega a Meta, y el resultado se recuerda durante 7 días:
| Resultado guardado | Respuesta al repetir | Qué significa |
|---|---|---|
succeeded | 200, mismo messageId | Meta aceptó el mensaje |
rejected | 422, mismo error | Meta lo rechazó definitivamente (p. ej. ventana de 24 h cerrada); repetir el mismo mensaje no ayuda |
unknown | 504 | Timeout o 5xx a mitad de la llamada — Meta pudo haberlo aceptado. No reintentes con una clave nueva: arriesgas un duplicado. Verifica primero (p. ej. con el evento status de tu webhook) |
aún pending | 409 in-flight | Una petición concurrente con la misma clave sigue en curso |
| — | 409 key-reuse | Misma clave con un cuerpo distinto — nunca se reproduce |
Dos fallos liberan la clave en lugar de guardar un resultado, porque el mensaje definitivamente no se transmitió y la misma clave puede reintentar: 429 rate-limited (límite de Meta) y 403 needs-reauth (credenciales revocadas). Un 503 temporary también puede reintentarse con la misma clave.
Enviar un mensaje
/api/sendEnvía un mensaje al número indicado. El cuerpo (máx. 64 KB) es una unión etiquetada estricta: los campos desconocidos se rechazan con las rutas del error en detail.
Petición
curl https://app.ceromanual.com/api/send \
-H "Authorization: Bearer cmk_..." \
-H "Idempotency-Key: 8f14e45f-ceea-4e2e-a9fd-3d1f4a1c2b7d" \
-H "Content-Type: application/json" \
-d '{"to": "5215512345678", "type": "text", "text": {"body": "Hola"}}'
| Cabecera | Valor |
|---|---|
Authorization | Bearer cmk_… |
Idempotency-Key | Un identificador único por mensaje (ver Idempotencia) |
Content-Type | application/json |
to es el teléfono del destinatario (7–15 dígitos, + opcional). Tipos de mensaje admitidos y su objeto de contenido:
| type | Contenido | Notas |
|---|---|---|
text | text: { body, preview_url? } | body hasta 4096 caracteres |
image | image: { id, caption? } | id de POST /api/media; caption hasta 1024 |
video | video: { id, caption? } | |
audio | audio: { id } | Sin caption (notas de voz: sube OPUS .ogg) |
document | document: { id, caption?, filename? } | filename hasta 240 caracteres |
sticker | sticker: { id } | WebP |
location | location: { latitude, longitude, name?, address? } | |
contacts | contacts: [ { name: { formatted_name, … }, phones?, … } ] | 1–10 contactos |
reaction | reaction: { message_id, emoji } | emoji vacío quita la reacción |
interactive | interactive: { type: "button" | "list", … } | Ver restricciones abajo |
template | template: { name, language: { code }, components? } | Una plantilla aprobada; ver abajo |
Mensajes interactivos (límites de Meta, validados antes de enviar): botones de respuesta — hasta 3, título hasta 20 caracteres, cuerpo hasta 1024; listas — etiqueta del botón hasta 20, hasta 10 secciones y 10 filas en total entre todas las secciones, título de fila hasta 24, descripción hasta 72, cuerpo hasta 4096.
Los archivos se referencian solo por el id devuelto al subirlos — no se aceptan URLs.
Plantillas. template es el único tipo que puede enviarse fuera de la ventana de 24 horas — así inicia o reabre tu sistema una conversación. name y language.code deben coincidir con una plantilla aprobada en la WABA del número (creada con POST /api/templates o en WhatsApp Manager); components rellena sus variables:
{
"to": "5215512345678",
"type": "template",
"template": {
"name": "confirmacion_pedido",
"language": { "code": "es_MX" },
"components": [
{ "type": "body", "parameters": [
{ "type": "text", "text": "Ana" },
{ "type": "text", "text": "A-1042" }
] }
]
}
}
Los parámetros del cuerpo son text, currency o date_time — posicionales (en el orden de {{1}}…{{n}}) o con nombre (parameter_name). El encabezado toma exactamente un parámetro text o un archivo por id (image/video/document). Botones: quick_reply lleva un payload, url lleva un text (el sufijo dinámico de la URL) e index es la posición del botón desde 0. Que los valores coincidan con la plantilla aprobada lo decide Meta — un desajuste (nombre desconocido, número de variables incorrecto, plantilla pausada) es un 422 definitivo con status: "rejected" y el código 132xxx de Meta. Los envíos de plantilla usan la misma Idempotency-Key que cualquier otro envío, y Meta cobra la conversación resultante a la WABA del propio número.
Respuesta
{
"status": "succeeded",
"replayed": false,
"messageId": "wamid.HBgL...",
"errorCode": null,
"errorMessage": null
}
replayed: true marca una repetición idempotente. En un rechazo, errorCode y errorMessage llevan el motivo saneado de Meta. Ten en cuenta que succeeded significa que Meta aceptó el mensaje — la confirmación de entrega llega después como evento status en tu webhook.
Errores
400 invalid-request (esquema con rutas de campo, o Idempotency-Key ausente o inválida), 401, 403 needs-reauth, 409 wrong-state / in-flight / key-reuse, 413, 422 rechazo definitivo, 429 (con Retry-After), 503, 504.
Subir un archivo
/api/mediaEl cuerpo son los bytes crudos del archivo (sin multipart). Cabeceras obligatorias: Authorization, Content-Type (el MIME real del archivo) y Content-Length (el tamaño exacto — las discrepancias se rechazan). Los bytes van directo a Meta; no almacenamos ni registramos nada.
curl https://app.ceromanual.com/api/media \
-H "Authorization: Bearer cmk_..." \
-H "Content-Type: image/jpeg" \
--data-binary @foto.jpg
| Categoría | Tipos MIME | Tamaño máx. |
|---|---|---|
| image | image/jpeg, image/png | 5 MB |
| sticker | image/webp | 500 KB (estáticos: Meta exige 100 KB) |
| audio | audio/aac, audio/amr, audio/mpeg, audio/mp4, audio/ogg (solo OPUS) | 16 MB |
| video | video/mp4, video/3gpp | 16 MB |
| document | text/plain, application/pdf, Word/Excel/PowerPoint (clásico y OOXML) | 100 MB |
Respuesta
{ "mediaId": "1013859600285441", "category": "image" }
Usa mediaId en POST /api/send. Meta caduca los ids subidos a los ~30 días.
Errores
411 length-required, 413 payload-too-large (declarado u observado), 415 unsupported-type, 422 upload-rejected (Meta rechazó el archivo), 429 too-many-streams o límite de peticiones. También aplican los códigos globales (401, 403, 409, 503) y el 504 de resultado incierto descrito arriba.
Descargar un archivo recibido
/api/media/{id}Transmite los bytes de un archivo — normalmente uno recibido en tu webhook. Sus ids caducan en Meta a los ~7 días, así que descárgalos pronto.
curl -OJ https://app.ceromanual.com/api/media/MEDIA_ID \
-H "Authorization: Bearer cmk_..."
Cabeceras de la respuesta: el Content-Type real, Content-Length cuando se conoce y Cache-Control: no-store. Un id desconocido devuelve 404 — igual que un id que pertenece a otro número, deliberadamente indistinguibles. Si el CDN de Meta responde de una forma no permitida, la descarga falla con 502 unsafe-target.
Crear una plantilla
/api/templatesCrea una plantilla de mensaje: un cuerpo de texto con variables posicionales opcionales. Las plantillas pertenecen a la cuenta de WhatsApp Business (WABA), no al número — los números que comparten WABA comparten plantillas. Cuando Meta la apruebe, envíala con POST /api/send (type: "template"). Listar, editar y borrar plantillas se hace por ahora en WhatsApp Manager.
No requiere Idempotency-Key: crear una plantilla no envía nada a nadie, y Meta rechaza por sí mismo un par (nombre, idioma) duplicado.
Petición
curl https://app.ceromanual.com/api/templates \
-H "Authorization: Bearer cmk_..." \
-H "Content-Type: application/json" \
-d '{
"name": "confirmacion_pedido",
"language": "es_MX",
"category": "UTILITY",
"body": "Hola {{1}}, tu pedido {{2}} está listo.",
"examples": ["Ana", "A-1042"]
}'
| Campo | Reglas |
|---|---|
name | Minúsculas, dígitos y guion bajo; hasta 512 caracteres |
language | Código de idioma de Meta: es_MX, es, en_US, … |
category | UTILITY (avisos de pedidos, citas, cuentas) o MARKETING (promociones). Meta puede recategorizar al revisar |
body | Hasta 1024 caracteres. Variables posicionales y consecutivas desde {{1}} — sin huecos, sin {{0}}, sin nombres |
examples | Obligatorio si el cuerpo tiene variables: un valor de muestra por variable (hasta 300 caracteres cada uno), en orden (Meta revisa con ellos). Se rechaza si no hay variables |
La categoría AUTHENTICATION no se ofrece: exige el cuerpo predefinido de Meta más un botón de código de un solo uso, que una plantilla de solo texto no puede expresar.
Respuesta
{ "id": "1234567890", "status": "PENDING", "category": "UTILITY" }
Errores
400 invalid-request (rutas de campo en detail), 401, 403 needs-reauth, 409 wrong-state, 413, 422 template-rejected (rechazo definitivo de Meta — nombre duplicado, política de contenido; detail lleva el motivo con código y subcódigo), 429 (con Retry-After), 503, y 504 unknown — la respuesta de Meta se perdió y la plantilla puede existir: revisa WhatsApp Manager antes de reintentar.
Tu webhook: configuración
Configura la URL por número en la consola (Webhook de entrega). Requisitos: HTTPS público en el puerto 443, sin credenciales en la URL, y el host no puede resolver a direcciones privadas o internas (se comprueba en cada entrega, no solo al guardar).
Al guardar, enviamos un POST de prueba a tu URL para confirmar que responde:
{
"version": 1,
"delivery_id": "dlv_verify_a1b2c3",
"event_type": "verification",
"occurred_at": "2026-07-18T10:32:00.000Z",
"data": {}
}
Tu endpoint solo tiene que responder 2xx — no hay ningún nonce que devolver. El cuerpo trae data vacío a propósito: no hay nada que leer ni que reenviar. Cualquier respuesta 2xx guarda la URL y activa la entrega. Un disparador de webhook de n8n, Make o Zapier ya responde 2xx por defecto, así que funciona sin tocar nada; en n8n, activa el flujo antes de guardar (un flujo inactivo responde 404).
Un receptor mínimo, sin dependencias, que responde 2xx y registra los eventos:
// node receptor.mjs — exponlo por HTTPS público (p. ej. ngrok http 8787);
// las URLs privadas o localhost se rechazan.
import { createServer } from "node:http";
createServer((req, res) => {
const chunks = [];
req.on("data", (c) => chunks.push(c));
req.on("end", () => {
const evento = JSON.parse(Buffer.concat(chunks).toString("utf8"));
// Contestar 200 es todo: no hay que devolver ningún dato, ni al guardar
// el webhook ni en cada entrega. El primer POST es de tipo "verification".
console.log(evento.event_type, evento.data);
res.writeHead(200).end();
});
}).listen(8787);
# python receptor.py — exponlo por HTTPS público (p. ej. ngrok http 8787);
# las URLs privadas o localhost se rechazan.
import json
from http.server import BaseHTTPRequestHandler, HTTPServer
class Receptor(BaseHTTPRequestHandler):
def do_POST(self):
largo = int(self.headers.get("Content-Length") or 0)
evento = json.loads(self.rfile.read(largo) or b"{}")
# Contestar 200 es todo: no hay que devolver ningún dato, ni al guardar
# el webhook ni en cada entrega. El primer POST es de tipo "verification".
print(evento["event_type"], evento["data"], flush=True)
self.send_response(200)
self.end_headers()
HTTPServer(("", 8787), Receptor).serve_forever()
Varios números pueden compartir una misma URL.
Si el guardado falla
El guardado responde {"error": "…"} con un código estable y, cuando no pudimos alcanzar tu endpoint, un reason legible por máquina.
| HTTP | error | reason.kind | Qué significa |
|---|---|---|---|
400 | invalid-url | — | No es una URL https:// válida en el puerto 443 |
422 | unsafe-url | — | El host resuelve a direcciones privadas o internas — no se permite |
422 | dns-failed | — | El dominio no resuelve en DNS — revisa que esté bien escrito |
422 | verification-failed | unreachable | No se pudo conectar (conexión rechazada o red inaccesible) |
422 | verification-failed | tls | El handshake TLS falló — revisa el certificado |
422 | verification-failed | timeout (timeoutMs) | No respondió dentro del plazo |
422 | verification-failed | non-2xx (status) | Respondió con ese código en vez de 2xx |
Eventos
Cada entrega es exactamente un evento con este sobre:
{
"version": 1,
"delivery_id": "dlv_9f8e7d",
"event_type": "message",
"occurred_at": "2026-07-18T10:32:00.000Z",
"data": { ... }
}
event_type: "message" — mensaje entrante
data lleva message_id, from?, from_user_id?, from_parent_user_id?, username?, type, occurred_at, un context_message_id opcional (respuestas) y un objeto de contenido que refleja los tipos de envío. Meta puede omitir from cuando el remitente usa username; en ese caso from_user_id conserva su identificador BSUID. Los archivos entrantes llevan id, mime_type?, caption?, filename? (saneado), voice? y una URL de descarga autenticada de este servicio — nunca los bytes ni la URL propia de Meta:
{
"message_id": "wamid.HBgL...",
"type": "image",
"from": "5215512345678",
"occurred_at": "2026-07-18T10:31:58.000Z",
"image": {
"id": "1013859600285441",
"url": "/api/media/1013859600285441",
"mime_type": "image/jpeg"
}
}
Los tipos de mensaje no admitidos llegan como { "type": "…", "unsupported": true }, sin contenido.
event_type: "message_echo" — mensaje enviado desde la app de WhatsApp Business
En números en coexistencia, un mensaje que el negocio envía desde la app de WhatsApp Business (o un dispositivo vinculado) llega a tu webhook como eco, para que tu sistema vea los dos lados de la conversación. data es igual que en message — message_id, type, occurred_at y los mismos contenidos — salvo que la contraparte es to (el usuario al que escribió el negocio) en vez de from. Los mensajes enviados por POST /api/send no se ecoan — esos ya los tienes. Ignora los event_type que no reconozcas; pueden añadirse nuevos.
event_type: "status" — estado de un mensaje enviado
data lleva message_id, status (sent / delivered / read / failed / played), recipient_id? y, en fallos, errors saneados (code, title). Un fallo sigue siendo un evento status, no un tipo nuevo. Por ejemplo, { "status": "failed", "errors": [{ "code": 131047, "title": "Re-engagement message" }] } indica que la ventana de 24 h terminó: envía una plantilla aprobada o espera a que la persona vuelva a escribir. El mismo motivo aparece en la burbuja correspondiente de Chats y como JSON en Actividad.
Firma de las entregas
Cada entrega (incluido el reto de verificación) llega con estas cabeceras:
X-Delivery-ID: dlv_9f8e7d
X-Timestamp: 1789035120
X-Retry-Attempt: 0
X-Signature: hex(HMAC-SHA256(secreto, timestamp + "." + cuerpo_crudo))
Verifica la firma sobre los bytes crudos exactos que recibiste — antes de parsear el JSON — y compara en tiempo constante:
const crypto = require("node:crypto");
const expected = crypto
.createHmac("sha256", process.env.WEBHOOK_SECRET)
.update(req.headers["x-timestamp"] + "." + rawBody)
.digest("hex");
const ok =
expected.length === req.headers["x-signature"].length &&
crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(req.headers["x-signature"]),
);
Rechaza timestamps antiguos (p. ej. de más de 5 minutos) para frenar la repetición de entregas capturadas.
Entrega y reintentos
- Al menos una vez, sin orden. Un evento puede llegar repetido y fuera de orden. Deduplica por
delivery_id(estable entre reintentos) y ordena poroccurred_ato por ids de mensaje cuando importe. - Responde
2xxrápido y haz el trabajo lento en segundo plano. Un408/425/429/5xx, un timeout o un error de red se reintenta con espera creciente: ~30 s → 2 min → 10 min → luego cada 30 min. Cualquier otro4xx(y cualquier redirección) es un fallo terminal para ese evento. - Los eventos no entregados caducan 24 horas después de recibirse.
- Los cuerpos de los eventos viven solo en un almacén de tránsito durante esas ≤ 24 h y se borran tras la entrega; quedan excluidos de las copias de seguridad.
- Quitar el webhook (o desconectar el número) detiene la entrega de inmediato y descarta la cola. Para rotar el secreto: quita el webhook, vuelve a guardar la misma URL y despliega el secreto nuevo.
- Mientras tu suscripción esté impaga o bloqueada, la entrega se pausa: los eventos nuevos siguen encolándose pero no se envían, y los que queden sin entregar caducan a las 24 h. La entrega se reanuda sola cuando el pago se regulariza.
Errores
Los errores devuelven {"error": "…"} (a veces con detail). Los que puedes encontrar en toda la API:
| HTTP | error | Qué hacer |
|---|---|---|
400 | invalid-request | Corrige el cuerpo o las cabeceras; detail trae las rutas de campo |
401 | invalid-api-key | Revisa la cabecera Authorization y que la clave siga activa |
403 | needs-reauth | Meta revocó las credenciales del número — reconéctalo desde la consola |
409 | wrong-state / in-flight / key-reuse | El número no está operativo, o hay conflicto de idempotencia (ver Idempotencia) |
413 | payload-too-large | Reduce el tamaño del cuerpo o del archivo |
422 | varios | Rechazo definitivo — repetir la misma petición no ayuda |
429 | rate-limited / too-many-streams | Espera los segundos de Retry-After y reintenta |
502 | unsafe-target | La descarga desde el CDN de Meta no se permitió — reintenta; si persiste, escríbenos |
503 | temporary | Fallo transitorio — reintenta con la misma Idempotency-Key |
504 | unknown | Resultado incierto — en envíos, no reintentes a ciegas con clave nueva; verifica primero |
Límites de uso
| Ámbito | Operación | Límite |
|---|---|---|
| Por clave API | Envíos | 60/min |
| Por clave API | Subidas de archivo | 10 inicios/min |
| Por clave API | Descargas de archivo | 30 inicios/min |
| Por clave API | Creación de plantillas | 10/min |
| Por número | Envíos | 30/min |
| Por número | Transferencias concurrentes | 2 en cada dirección |
| Por número | Creación de plantillas | 10/min |
Al superarlos, la respuesta es 429 con Retry-After (segundos). Los límites propios de Meta también aparecen como 429 rate-limited al enviar, igualmente con Retry-After.
Qué no hace esta API
Límites visibles de la versión actual, para que no los descubras a mitad de integración:
- No gestiona plantillas más allá de crearlas y enviarlas — listar, editar y borrar se hace en WhatsApp Manager.
- No importa el historial de chats.
- No garantiza orden de entrega ni exactamente-una-vez (ver Entrega y reintentos).
- No ofrece alta disponibilidad.
Si necesitas alguna de estas capacidades, escríbenos — se priorizan por demanda.