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) ymessage.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. |
eventIdes único por evento. Si Arplyx reintenta la entrega, eleventIdno cambia: usalo para deduplicar.typediscrimina el shape dedata. 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/readllegan vía el webhook de tu app de Meta (guía). - WhatsApp Direct:
delivered/readllegan por la sesión vinculada, típicamente a los pocos segundos. Elreaddepende de que el destinatario tenga activadas las confirmaciones de lectura en WhatsApp; si las tiene apagadas, el mensaje queda endelivered.
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 ennull.
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:
- Extraé
tyv1del header. - Rechazá si
|ahora - t| > 300segundos (anti-replay). - Calculá
HMAC-SHA256(secret, t + "." + body crudo)y comparalo conv1en 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
200inmediato 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 enGET /webhook-endpoints). - Arplyx no garantiza el orden de entrega entre eventos. Usá
data.timestamp/data.receivedAty eleventIdpara reconstruir.
Buenas prácticas del receptor
- Deduplicá por
eventId: los reintentos re-entregan el mismo evento con el mismo id. - Respondé 200 antes de procesar: encolá el evento y procesalo async; evitás timeouts y reintentos innecesarios.
- Tolerá campos nuevos en
datay tipos de evento desconocidos. - 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.