API · v1 Consola →

Referencia de la API

Todo el contrato de la API y del webhook de entrega: autenticación, envíos idempotentes, transferencia de archivos, eventos firmados y límites de uso.

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.

URL base
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 APICrear 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:

Tu primer envío
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í:

POST a tu webhook — confirmación de entrega
{
  "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:

Cabecera de autenticación
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-key significa: 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 con 409 wrong-state hasta que lo resuelvas en la consola. 403 needs-reauth aparece 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 guardadoRespuesta al repetirQué significa
succeeded200, mismo messageIdMeta aceptó el mensaje
rejected422, mismo errorMeta lo rechazó definitivamente (p. ej. ventana de 24 h cerrada); repetir el mismo mensaje no ayuda
unknown504Timeout 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 pending409 in-flightUna petición concurrente con la misma clave sigue en curso
409 key-reuseMisma 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.

Endpoints

Enviar un mensaje

POST/api/send

Enví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

Ejemplo — mensaje de texto
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"}}'
CabeceraValor
AuthorizationBearer cmk_…
Idempotency-KeyUn identificador único por mensaje (ver Idempotencia)
Content-Typeapplication/json

to es el teléfono del destinatario (7–15 dígitos, + opcional). Tipos de mensaje admitidos y su objeto de contenido:

typeContenidoNotas
texttext: { body, preview_url? }body hasta 4096 caracteres
imageimage: { id, caption? }id de POST /api/media; caption hasta 1024
videovideo: { id, caption? }
audioaudio: { id }Sin caption (notas de voz: sube OPUS .ogg)
documentdocument: { id, caption?, filename? }filename hasta 240 caracteres
stickersticker: { id }WebP
locationlocation: { latitude, longitude, name?, address? }
contactscontacts: [ { name: { formatted_name, … }, phones?, … } ]1–10 contactos
reactionreaction: { message_id, emoji }emoji vacío quita la reacción
interactiveinteractive: { type: "button" | "list", … }Ver restricciones abajo
templatetemplate: { 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:

Ejemplo — enviar una plantilla con 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

200 OK
{
  "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

POST/api/media

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

Ejemplo — subir una imagen
curl https://app.ceromanual.com/api/media \
  -H "Authorization: Bearer cmk_..." \
  -H "Content-Type: image/jpeg" \
  --data-binary @foto.jpg
CategoríaTipos MIMETamaño máx.
imageimage/jpeg, image/png5 MB
stickerimage/webp500 KB (estáticos: Meta exige 100 KB)
audioaudio/aac, audio/amr, audio/mpeg, audio/mp4, audio/ogg (solo OPUS)16 MB
videovideo/mp4, video/3gpp16 MB
documenttext/plain, application/pdf, Word/Excel/PowerPoint (clásico y OOXML)100 MB

Respuesta

200 OK
{ "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

GET/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.

Ejemplo — descargar
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

POST/api/templates

Crea 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

Ejemplo — plantilla de utilidad con variables
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"]
  }'
CampoReglas
nameMinúsculas, dígitos y guion bajo; hasta 512 caracteres
languageCódigo de idioma de Meta: es_MX, es, en_US, …
categoryUTILITY (avisos de pedidos, citas, cuentas) o MARKETING (promociones). Meta puede recategorizar al revisar
bodyHasta 1024 caracteres. Variables posicionales y consecutivas desde {{1}} — sin huecos, sin {{0}}, sin nombres
examplesObligatorio 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

200 OK
{ "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

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:

POST a tu URL — evento de verificación
{
  "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 2xxno 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:

Receptor mínimo — Node.js
// 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);
Receptor mínimo — Python
# 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.

HTTPerrorreason.kindQué significa
400invalid-urlNo es una URL https:// válida en el puerto 443
422unsafe-urlEl host resuelve a direcciones privadas o internas — no se permite
422dns-failedEl dominio no resuelve en DNS — revisa que esté bien escrito
422verification-failedunreachableNo se pudo conectar (conexión rechazada o red inaccesible)
422verification-failedtlsEl handshake TLS falló — revisa el certificado
422verification-failedtimeout (timeoutMs)No respondió dentro del plazo
422verification-failednon-2xx (status)Respondió con ese código en vez de 2xx

Eventos

Cada entrega es exactamente un evento con este sobre:

Sobre del evento
{
  "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:

data — mensaje entrante con imagen
{
  "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 messagemessage_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:

Cabeceras de cada entrega
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:

Verificación en Node.js
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 por occurred_at o por ids de mensaje cuando importe.
  • Responde 2xx rápido y haz el trabajo lento en segundo plano. Un 408/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 otro 4xx (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.
Referencia

Errores

Los errores devuelven {"error": "…"} (a veces con detail). Los que puedes encontrar en toda la API:

HTTPerrorQué hacer
400invalid-requestCorrige el cuerpo o las cabeceras; detail trae las rutas de campo
401invalid-api-keyRevisa la cabecera Authorization y que la clave siga activa
403needs-reauthMeta revocó las credenciales del número — reconéctalo desde la consola
409wrong-state / in-flight / key-reuseEl número no está operativo, o hay conflicto de idempotencia (ver Idempotencia)
413payload-too-largeReduce el tamaño del cuerpo o del archivo
422variosRechazo definitivo — repetir la misma petición no ayuda
429rate-limited / too-many-streamsEspera los segundos de Retry-After y reintenta
502unsafe-targetLa descarga desde el CDN de Meta no se permitió — reintenta; si persiste, escríbenos
503temporaryFallo transitorio — reintenta con la misma Idempotency-Key
504unknownResultado incierto — en envíos, no reintentes a ciegas con clave nueva; verifica primero

Límites de uso

ÁmbitoOperaciónLímite
Por clave APIEnvíos60/min
Por clave APISubidas de archivo10 inicios/min
Por clave APIDescargas de archivo30 inicios/min
Por clave APICreación de plantillas10/min
Por númeroEnvíos30/min
Por númeroTransferencias concurrentes2 en cada dirección
Por númeroCreación de plantillas10/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.

Consola · ceromanual.com