Saltar a contenido

Webhooks

Los webhooks te avisan en tiempo real de lo que pasa con tus mensajes, sin polling: Arplyx hace un POST a una URL tuya cada vez que un mensaje cambia de estado o cuando llega un mensaje entrante a tus cuentas.

  • Disponibilidad: planes Basic y Pro.
  • Eventos: message.status (transiciones de estado) y message.inbound (mensajes entrantes).
  • Seguridad: cada request va firmado con HMAC-SHA256 sobre el body crudo.
  • Confiabilidad: reintentos automáticos con backoff durante ~12 horas y un eventId único para deduplicar.

¿API o webhooks?

Son complementarios: usá webhooks para reaccionar a eventos, y la API de consulta (GET /messages) para reconciliar en lote — por ejemplo, tras una caída de tu sistema.

Registrar un endpoint

Los endpoints se gestionan por API con tu x-api-key, o desde el portal en la sección Webhooks (donde además podés ver el secreto cuando lo necesites, revisar el historial de entregas de cada endpoint con su resultado HTTP, y reintentar manualmente una entrega fallida). Podés tener hasta 5 endpoints activos.

Método Ruta Descripción
POST /webhook-endpoints Crea un endpoint. Devuelve el secret una única vez.
GET /webhook-endpoints Lista tus endpoints (sin el secreto).
GET /webhook-endpoints/{id} Detalle de un endpoint.
PATCH /webhook-endpoints/{id} Modifica url, events, description o isActive.
DELETE /webhook-endpoints/{id} Elimina el endpoint (y su historial de entregas).
POST /webhook-endpoints/{id}/rotate-secret Genera un secreto nuevo (lo devuelve una única vez).
POST /webhook-endpoints/{id}/test Encola un evento webhook.test para probar tu receptor.
curl -X POST https://api.arplyx.com/webhook-endpoints \
  -H "x-api-key: ak_live_xxxxxxxx..." \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://api.tuapp.com/webhooks/arplyx",
    "events": ["message.status", "message.inbound"],
    "description": "Producción"
  }'

Respuesta 201:

{
  "id": "3f6c1c2e-...",
  "url": "https://api.tuapp.com/webhooks/arplyx",
  "description": "Producción",
  "events": ["message.status", "message.inbound"],
  "isActive": true,
  "lastSuccessAt": null,
  "lastFailureAt": null,
  "lastFailureReason": null,
  "createdAt": "2026-07-04T12:00:00.000Z",
  "updatedAt": "2026-07-04T12:00:00.000Z",
  "secret": "whsec_kJ8s...  ← guardalo ya: no se vuelve a mostrar por API"
}

Guardá el secret

El secret (whsec_…) se usa para verificar la firma de cada entrega. Se devuelve solo al crear el endpoint o al rotarlo. Guardalo en tu gestor de secretos. Si lo perdés, rotalo con POST /webhook-endpoints/{id}/rotate-secret.

Requisitos de la URL: https obligatorio, sin credenciales embebidas, y no puede apuntar a hosts privados o de loopback (localhost, 10.x, 192.168.x, etc.). Si no cumple, la API responde 400 invalid_webhook_url con el motivo.

El envelope

Toda entrega es un POST con Content-Type: application/json y este formato:

{
  "eventId": "evt_9f8e7d6c-1a2b-4c3d-8e9f-0a1b2c3d4e5f",
  "type": "message.status",
  "createdAt": "2026-07-04T12:00:00.000Z",
  "data": { "…": "…" }
}

Headers de cada request:

Header Contenido
x-arplyx-event El type del evento (para rutear sin parsear el body).
x-arplyx-event-id El eventId (para deduplicar sin parsear el body).
x-arplyx-signature La firma: t=<unix seconds>,v1=<hmac hex>. Ver verificación.
  • eventId es único por evento. Si Arplyx reintenta la entrega, el eventId no cambia: usalo para deduplicar.
  • type discrimina el shape de data. Ignorá tipos que no conozcas (podemos agregar eventos nuevos).

Evento message.status

Se emite en cada transición de estado de un mensaje tuyo: a sent, delivered, read o failed. (La transición a queued no se emite: ya la conocés por la respuesta de POST /messages.)

{
  "eventId": "evt_…",
  "type": "message.status",
  "createdAt": "2026-07-04T12:00:05.000Z",
  "data": {
    "messageId": "b7a19c4d-…",
    "externalId": "turno-4812-recordatorio",
    "status": "delivered",
    "previousStatus": "sent",
    "channel": "whatsapp_direct",
    "recipient": "+5493416631539",
    "whatsappAccountId": "9d2f81b3-…",
    "timestamp": "2026-07-04T12:00:04.000Z",
    "error": null
  }
}
Campo Descripción
messageId El id de Arplyx (el que devuelve POST /messages).
externalId Tu identificador del mensaje.
status El estado nuevo: sent, delivered, read o failed.
previousStatus El estado anterior a la transición.
timestamp Cuándo ocurrió la transición (timestamp del proveedor cuando está disponible).
error Solo cuando status es failed: { "code": "…", "message": "…" }. null en el resto.

Los estados avanzan de forma monótona (sent → delivered → read); nunca vas a recibir un evento que retroceda. Pero el orden de entrega entre eventos no está garantizado — si te llega read antes que delivered, aplicá la monotonicidad de tu lado (o simplemente quedate con el estado de mayor rango).

Estados por canal

  • Meta Official: delivered/read llegan vía el webhook de tu app de Meta (guía).
  • WhatsApp Direct: delivered/read llegan por la sesión vinculada, típicamente a los pocos segundos. El read depende de que el destinatario tenga activadas las confirmaciones de lectura en WhatsApp; si las tiene apagadas, el mensaje queda en delivered.

Evento message.inbound

Se emite cuando llega un mensaje entrante a cualquiera de tus cuentas (ambos canales).

{
  "eventId": "evt_…",
  "type": "message.inbound",
  "createdAt": "2026-07-04T12:03:00.000Z",
  "data": {
    "inboundMessageId": "5c31a9e8-…",
    "whatsappAccountId": "9d2f81b3-…",
    "channel": "whatsapp_direct",
    "from": "+5493416631539",
    "fromDisplayName": "Ana García",
    "messageType": "text",
    "text": "sí, confirmo",
    "receivedAt": "2026-07-04T12:02:58.000Z",
    "replyTo": {
      "messageId": "b7a19c4d-…",
      "externalId": "cupo-liberado-oferta-77",
      "providerMessageId": "3EB0A1B2C3D4"
    }
  }
}
Campo Descripción
from Remitente en E.164. Puede ser null si WhatsApp oculta el número (cuentas con privacidad LID); en ese caso usá fromDisplayName.
messageType text, image, video, audio, voice_note, document, sticker, reaction, location, contact
text El texto del mensaje (o el caption del medio). null para contenido sin texto.
replyTo Presente solo si el usuario respondió a un mensaje usando "responder" de WhatsApp.

replyTo: saber a qué mensaje respondieron

Cuando el usuario responde citando un mensaje, replyTo trae:

  • providerMessageId: el id WhatsApp del mensaje citado (siempre).
  • messageId / externalId: tus identificadores, si el mensaje citado fue enviado vía Arplyx y todavía está dentro de la retención. Si no (mensaje ajeno o ya purgado), vienen en null.

Esto resuelve el caso típico: mandás una oferta con externalId: "cupo-77", el usuario responde "sí" citándola, y el evento te dice exactamente a qué oferta corresponde ese "sí".

Verificar la firma

Cada entrega lleva el header:

x-arplyx-signature: t=1751630400,v1=5f8a1c…e3d9

donde v1 = HMAC-SHA256(secret, "{t}.{body crudo}") en hex. Para verificar:

  1. Extraé t y v1 del header.
  2. Rechazá si |ahora - t| > 300 segundos (anti-replay).
  3. Calculá HMAC-SHA256(secret, t + "." + body crudo) y comparalo con v1 en tiempo constante.

Body crudo

La firma es sobre los bytes crudos del body, antes de parsear el JSON. Configurá tu framework para conservarlos (en Express, express.raw() o el verify callback de express.json()).

import crypto from 'node:crypto';
import express from 'express';

const app = express();
const SECRET = process.env.ARPLYX_WEBHOOK_SECRET; // whsec_…

app.post(
  '/webhooks/arplyx',
  express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }),
  (req, res) => {
    const header = req.header('x-arplyx-signature') ?? '';
    const parts = Object.fromEntries(
      header.split(',').map((p) => p.split('=', 2))
    );
    const t = Number(parts.t);
    if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) {
      return res.status(401).end();
    }
    const expected = crypto
      .createHmac('sha256', SECRET)
      .update(`${parts.t}.`)
      .update(req.rawBody)
      .digest('hex');
    const ok =
      parts.v1?.length === expected.length &&
      crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
    if (!ok) return res.status(401).end();

    // Respondé 200 rápido y procesá async
    res.status(200).end();
    processEvent(req.body); // { eventId, type, createdAt, data }
  }
);
import hashlib, hmac, os, time
from fastapi import FastAPI, Header, HTTPException, Request

app = FastAPI()
SECRET = os.environ["ARPLYX_WEBHOOK_SECRET"].encode()  # whsec_…

@app.post("/webhooks/arplyx")
async def arplyx_webhook(request: Request, x_arplyx_signature: str = Header("")):
    raw = await request.body()
    parts = dict(p.split("=", 1) for p in x_arplyx_signature.split(",") if "=" in p)
    t = parts.get("t", "")
    if not t.isdigit() or abs(time.time() - int(t)) > 300:
        raise HTTPException(status_code=401)
    expected = hmac.new(SECRET, f"{t}.".encode() + raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, parts.get("v1", "")):
        raise HTTPException(status_code=401)

    event = await request.json()  # { eventId, type, createdAt, data }
    # encolá el procesamiento y respondé rápido
    return {"ok": True}

Entregas y reintentos

  • Se considera entregado con cualquier respuesta 2xx. Redirecciones (3xx) y errores cuentan como fallo.
  • Timeout por intento: 10 segundos. Respondé rápido (idealmente 200 inmediato y procesamiento asíncrono).
  • Ante un fallo: hasta 8 intentos con backoff exponencial (~30 s, 2 min, 8 min, 32 min, ~2 h y luego cada ~4 h) — una ventana total de ~12 horas.
  • Agotados los intentos, la entrega queda descartada. El endpoint registra lastFailureAt/lastFailureReason (visible en GET /webhook-endpoints).
  • Arplyx no garantiza el orden de entrega entre eventos. Usá data.timestamp / data.receivedAt y el eventId para reconstruir.

Buenas prácticas del receptor

  1. Deduplicá por eventId: los reintentos re-entregan el mismo evento con el mismo id.
  2. Respondé 200 antes de procesar: encolá el evento y procesalo async; evitás timeouts y reintentos innecesarios.
  3. Tolerá campos nuevos en data y tipos de evento desconocidos.
  4. Verificá la firma siempre, incluso en staging.

Alternativa liviana: reenvío HTTP por reglas

Si solo necesitás los mensajes entrantes y no querés gestionar endpoints ni firmas, las Reglas de reenvío (plan Pro) aceptan un destino de tipo http: cada mensaje que matchee la regla se POSTea a tu URL con el mismo shape que el data de message.inbound, más un header x-arplyx-forward-rule con el id de la regla.

Diferencias con los webhooks: va sin firma y sin reintentos (un solo intento, timeout 10 s), pero permite filtrar por condición (contiene, empieza con, regex) antes de llegar a tu servidor. Para automatización de producción recomendamos webhooks.