Ir al contenido

Webhooks ​

ZAPPIO llama a tu servidor cuando algo pasa: llega un mensaje, un mensaje cambia de estado, una instancia se conecta o necesita un QR nuevo. Registras un endpoint (una URL), eliges los eventos que quieres y verificas una firma en cada petición.

Empezar ​

Crear y editar endpoints necesita el scope webhooks:write (y tu correo verificado); consultarlos, webhooks:read. También puedes verlos y administrarlos en el panel, en Webhooks.

bash
# 1. Registra un endpoint. La respuesta incluye el secreto de firma UNA sola vez.
curl -X POST "https://zappio.cloud/api/v1/webhooks" \
  -H "Authorization: Bearer $ZAPPIO_API_KEY" -H "Content-Type: application/json" \
  -d '{"url": "https://tu-app.com/webhooks/zappio", "events": ["message.received", "message.delivered"]}'

# 2. Pide a ZAPPIO que envíe un evento de prueba a ese endpoint.
curl -X POST "https://zappio.cloud/api/v1/webhooks/$WEBHOOK_ID/test" \
  -H "Authorization: Bearer $ZAPPIO_API_KEY"

Guarda el secreto (empieza con zpwh_) en una variable de entorno de tu servidor. Después, tu servidor verifica la firma y responde cualquier 2xx.

Un espacio de trabajo puede tener un número limitado de endpoints (5 por defecto); al llegar al límite, crear otro responde 403 WEBHOOK_LIMIT_REACHED.

Eventos ​

EventoSe envía cuando
message.receivedLlega un mensaje entrante (texto o multimedia, de una persona o de un grupo).
message.sentWhatsApp aceptó un mensaje saliente.
message.deliveredUn mensaje saliente llegó al teléfono del destinatario.
message.readEl destinatario leyó un mensaje saliente.
message.failedUn mensaje saliente no se pudo enviar.
instance.connectedUna instancia terminó de conectarse.
instance.disconnectedUna instancia se desconectó, o se cerró su sesión desde el teléfono.
instance.qr_requiredUna instancia necesita que escanees un QR.
instance.errorUna instancia falló.

Tú eliges cuáles de estos nueve recibe cada endpoint (events, hasta 20 nombres, sin repetir). El evento webhook.test se envía siempre al endpoint que pruebas, sin importar a qué esté suscrito. Si un evento de mensaje se salta un recibo (por ejemplo, WhatsApp reporta read sin delivered), ZAPPIO igual emite los eventos en orden: siempre ves sent → delivered → read.

La petición que recibes ​

ZAPPIO envía POST <tu url> con Content-Type: application/json y estos headers:

HeaderValor
User-AgentZAPPIO-Webhooks/1.0
X-Zappio-EventEl tipo de evento, por ejemplo message.delivered.
X-Zappio-DeliveryEl id de la entrega (whd_...). Es el mismo en todos los reintentos de una entrega.
X-Zappio-TimestampHora Unix, en segundos, en que se firmó este intento. Cambia en cada intento.
X-Zappio-Signaturev1= seguido de 64 caracteres hexadecimales.

El cuerpo es un objeto JSON con siempre los mismos cinco campos:

CampoTipoSignifica
idevt_...Id del evento. Único por evento; idéntico en cada reintento.
typestringUno de los nombres de evento de arriba.
created_atstringUTC con microsegundos y sufijo Z.
workspace_idten_...Tu espacio de trabajo.
dataobjetoDepende del evento.
json
{
  "id": "evt_01EXAMPLE00000000000000003",
  "type": "message.delivered",
  "created_at": "2026-01-01T12:00:00.000000Z",
  "workspace_id": "ten_01EXAMPLE00000000000000000",
  "data": {
    "message": {
      "id": "msg_01EXAMPLE00000000000000000",
      "instance_id": "ins_01EXAMPLE00000000000000000",
      "direction": "outbound",
      "type": "text",
      "status": "delivered",
      "to": "5255000000001",
      "text": "Tu pedido va en camino",
      "...": "..."
    }
  }
}

Detalles que importan al leerlo:

  • Los mensajes de grupo traen el id público del grupo en group_id (grp_...); from es el número de quien escribió.
  • Los objetos instance omiten los campos vacíos. id, name y status siempre están; no asumas que existe otra clave.
  • La multimedia no viene incluida: el mensaje trae media.available; descarga el archivo con GET /messages/{message}/media (Multimedia).
  • No dependas de campos que no aparezcan en los ejemplos de cada evento: pueden añadirse campos opcionales sin aviso.

Verificar la firma ​

Cada petición lleva una firma HMAC para que sepas que viene de ZAPPIO y no fue alterada:

firma = "v1=" + hex( HMAC-SHA256( secreto, timestamp + "." + cuerpo_crudo ) )
  • secreto es el valor zpwh_... que recibiste al crear el endpoint o rotar su secreto.
  • timestamp es el header X-Zappio-Timestamp tal como llegó (dígitos decimales).
  • cuerpo_crudo son los bytes del cuerpo exactamente como llegaron. En la mayoría de los frameworks eso significa leer el cuerpo crudo antes de convertirlo a JSON.

Pasos:

  1. Lee el cuerpo crudo, X-Zappio-Timestamp y X-Zappio-Signature.
  2. Rechaza la petición si el timestamp no es un número o difiere de tu reloj por más de 5 minutos (en cualquier sentido). Así nadie puede reenviar una petición capturada.
  3. Calcula la firma esperada y compárala con el header con una comparación de tiempo constante.
  4. Solo entonces interpreta el JSON y actúa.

Verifica los bytes originales

Verifica el cuerpo tal como llegó, no una copia que volviste a serializar: cualquier cambio de espacios u orden de claves invalida la firma.

Implementaciones mínimas, sin dependencias, probadas contra un vector de prueba:

js
'use strict';
const crypto = require('node:crypto');

/**
 * Verifies a ZAPPIO webhook. `rawBody` must be the exact bytes received (a Buffer or the unparsed string), not
 * JSON.stringify(req.body). No dependencies.
 */
function verifyZappioWebhook(secret, timestamp, signatureHeader, rawBody, now = Math.floor(Date.now() / 1000), tolerance = 300) {
  // Reject stale or future timestamps: a captured request cannot be replayed later.
  if (!/^\d+$/.test(timestamp) || Math.abs(now - Number(timestamp)) > tolerance) return false;

  const expected = 'v1=' + crypto.createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest('hex');
  const a = Buffer.from(expected);
  const b = Buffer.from(String(signatureHeader));
  return a.length === b.length && crypto.timingSafeEqual(a, b); // constant-time comparison
}

module.exports = { verifyZappioWebhook };

// --- Self-check against the shared test vector: node verify-webhook.js webhook-signature-vector.json
if (require.main === module && process.argv[2]) {
  const vector = JSON.parse(require('node:fs').readFileSync(process.argv[2], 'utf8'));
  let failed = 0;
  for (const c of vector.cases) {
    const ok = verifyZappioWebhook(c.secret, c.timestamp, c.signature, c.body, c.now, vector.tolerance_seconds);
    if (ok !== c.valid) {
      console.error(`FAIL node: ${c.name}`);
      failed++;
    }
  }
  if (failed === 0) console.log(`node: ${vector.cases.length} cases OK`);
  process.exit(failed === 0 ? 0 : 1);
}
php
<?php

/**
 * Verifies a ZAPPIO webhook. Pass the RAW request body (the exact bytes received), the X-Zappio-Timestamp header and the
 * X-Zappio-Signature header. No dependencies.
 */
function verifyZappioWebhook(string $secret, string $timestamp, string $signatureHeader, string $rawBody, ?int $now = null, int $tolerance = 300): bool
{
    // Reject stale or future timestamps: a captured request cannot be replayed later.
    if (! ctype_digit($timestamp) || abs(($now ?? time()) - (int) $timestamp) > $tolerance) {
        return false;
    }

    $expected = 'v1='.hash_hmac('sha256', $timestamp.'.'.$rawBody, $secret);

    return hash_equals($expected, $signatureHeader); // constant-time comparison
}

// --- Self-check against the shared test vector: php verify-webhook.php webhook-signature-vector.json
if (PHP_SAPI === 'cli' && isset($argv[1]) && realpath($_SERVER['SCRIPT_FILENAME']) === __FILE__) {
    $vector = json_decode((string) file_get_contents($argv[1]), true, flags: JSON_THROW_ON_ERROR);
    $failed = 0;
    foreach ($vector['cases'] as $case) {
        $ok = verifyZappioWebhook($case['secret'], $case['timestamp'], $case['signature'], $case['body'], $case['now'], $vector['tolerance_seconds']);
        if ($ok !== $case['valid']) {
            fwrite(STDERR, "FAIL php: {$case['name']}\n");
            $failed++;
        }
    }
    echo $failed === 0 ? 'php: '.count($vector['cases'])." cases OK\n" : '';
    exit($failed === 0 ? 0 : 1);
}
py
import hashlib
import hmac
import json
import sys
import time


def verify_zappio_webhook(secret, timestamp, signature_header, raw_body, now=None, tolerance=300):
    """Verifies a ZAPPIO webhook. raw_body must be the exact bytes received (bytes or str). No dependencies."""
    now = int(time.time()) if now is None else now
    # Reject stale or future timestamps: a captured request cannot be replayed later.
    if not timestamp.isascii() or not timestamp.isdigit() or abs(now - int(timestamp)) > tolerance:
        return False

    body = raw_body.encode() if isinstance(raw_body, str) else raw_body
    expected = "v1=" + hmac.new(secret.encode(), timestamp.encode() + b"." + body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature_header)  # constant-time comparison


# --- Self-check against the shared test vector: python verify_webhook.py webhook-signature-vector.json
if __name__ == "__main__" and len(sys.argv) > 1:
    with open(sys.argv[1], encoding="utf-8") as handle:
        vector = json.load(handle)
    failed = 0
    for case in vector["cases"]:
        ok = verify_zappio_webhook(case["secret"], case["timestamp"], case["signature"], case["body"], case["now"], vector["tolerance_seconds"])
        if ok != case["valid"]:
            print("FAIL python: " + case["name"], file=sys.stderr)
            failed += 1
    if failed == 0:
        print("python: %d cases OK" % len(vector["cases"]))
    sys.exit(0 if failed == 0 else 1)

Vector de prueba ​

webhook-signature-vector.json es un vector determinista con datos ficticios (secreto inventado, timestamp fijo, cuerpo fijo) y once casos que deben ser aceptados o rechazados: una petición válida, los bordes de la ventana de tiempo, un cuerpo modificado, un cuerpo reserializado, un secreto equivocado, un timestamp modificado, un prefijo ausente y valores mal formados. Úsalo para probar tu propia implementación.

secreto    zpwh_EXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLE0
timestamp  1767225600
firma      v1=f9d5546397737d2e17d351017e935118ca61ba644071ad2a6c3b1777bdfda077

Tu respuesta, reintentos y duplicados ​

  • Responde cualquier 2xx en menos de 10 segundos (3 para conectar). Haz el trabajo real después; ponlo en una cola.
  • Cualquier otra cosa es un fallo: otro código de estado, un tiempo de espera, un error de conexión o una redirección (no se siguen redirecciones; registra la URL final).
  • Reintentos. Hasta 8 intentos en total (unas 45 horas). Tras un intento fallido ZAPPIO espera, en orden: 1 minuto, 5 minutos, 30 minutos, 2 horas, 6 horas, 12 horas y 24 horas. Tras el octavo fallo la entrega queda exhausted.
  • Al menos una vez. El mismo evento puede llegarte más de una vez (un reintento tras un tiempo de espera que sí respondiste, un reintento manual). Deduplica por event.id.
  • Sin garantía de orden. Los eventos de una conversación pueden llegar desordenados, sobre todo alrededor de reintentos. Usa created_at y el status del mensaje para decidir qué es lo más reciente.
  • Protección por endpoint. ZAPPIO envía como máximo 2 peticiones a la vez y 120 por minuto a un mismo endpoint. Por encima de eso, las entregas se posponen, no se pierden ni cuentan como intento fallido.

Estados de una entrega: pending (espera su primer intento), retrying (falló, hay otro intento programado), success, exhausted (se usaron todos los intentos) y failed (se detuvo sin reintentar porque el endpoint se deshabilitó, se eliminó o su URL dejó de ser segura). Los eventos, entregas e intentos se eliminan a los 30 días.

Qué URLs se aceptan ​

ZAPPIO se protege, y te protege, de que lo apunten a sistemas internos. La URL de un endpoint debe:

  • usar https;
  • usar el puerto 443 (el predeterminado) o 8443;
  • no llevar usuario ni contraseña, ni #fragmento;
  • tener un host que resuelva, y todas las direcciones a las que resuelve deben ser públicas. Se rechazan las direcciones privadas, de loopback, link-local y otros rangos reservados.

Estas reglas se revisan al crear o editar el endpoint (422 INVALID_WEBHOOK_URL) y otra vez en cada intento. Una URL que deja de ser válida deja sus entregas en failed con UNSAFE_URL.

Probar y administrar un endpoint ​

ObjetivoLlamadaNotas
Enviar un evento de pruebaPOST /webhooks/{webhook}/test202. El endpoint debe estar habilitado (409 WEBHOOK_DISABLED). Aparece como una entrega normal.
Ver qué pasóGET /webhooks/{webhook}/deliveries, GET /webhook-deliveries/{delivery}El detalle incluye el cuerpo enviado y cada intento.
Reintentar una entregaPOST /webhook-deliveries/{delivery}/retryPara una entrega terminada de un endpoint habilitado. 409 WEBHOOK_RETRY_NOT_ALLOWED en otro caso.
Reintentar todo lo que fallóPOST /webhooks/{webhook}/deliveries/retry-failedVuelve a encolar hasta 100 entregas; responde { requeued, remaining }. Repite mientras remaining > 0.
Cambiar url, eventos o descripciónPATCH /webhooks/{webhook}Cada campo es opcional.
Pausar o reanudarPATCH /webhooks/{webhook} con status: disabled o enabledAl deshabilitar, sus entregas abiertas pasan a failed.
Rotar el secretoPOST /webhooks/{webhook}/rotate-secretVer abajo.
EliminarDELETE /webhooks/{webhook}204.

Rotar el secreto. El secreto nuevo se devuelve una sola vez y reemplaza al anterior de inmediato; no hay ventana de solapamiento. Las entregas pendientes (incluidos reintentos programados) se firman con el nuevo. Si puedes, despliega primero el secreto nuevo en tu receptor, o acepta ambos durante unos minutos.

Deshabilitación automática ​

Un endpoint que solo falla se apaga para no reintentar eternamente. Tras 48 horas de fallos continuos (con al menos una entrega exhausted), los miembros del espacio de trabajo que administran webhooks reciben un correo de aviso. Tras 72 horas (y al menos 24 horas después del aviso) el endpoint se deshabilita: status: disabled y disabled_reason: "auto_no_delivery_72h".

Una sola entrega exitosa reinicia la cuenta. Para reanudar: arregla tu receptor, haz PATCH /webhooks/{webhook} con { "status": "enabled" } y recupera lo que se perdió con POST /webhooks/{webhook}/deliveries/retry-failed. Los eventos con más de 30 días ya no existen.