WhatsApp Messaging API

Servicio HTTP para enviar y leer mensajes de WhatsApp desde tu propia integración — Odoo, n8n, Zapier, scripts a medida o cualquier cosa que pueda hacer un POST.

Base URL: https://messages.odoo.blue Content-Type: application/json UTF-8

Cómo empezar

El acceso a esta API se otorga por token. Sigue estos pasos para empezar a enviar y recibir mensajes desde tu número de WhatsApp:

  1. 1

    Adquiere tu token

    Ve a exdoo.mx y contrata un plan. Una vez completada la compra, Exdoo te entregará por correo:

    • Tu token personal (cadena de ~32 caracteres). No se vuelve a mostrar; guárdalo bien.
    • Un link de onboarding personalizado: https://messages.odoo.blue/onboard?token=…

    Nota: la página de compra en exdoo.mx está en construcción. Mientras tanto, escribe a Exdoo para obtener tu token manualmente.

  2. 2

    Abre tu link de onboarding

    Abre el link que te envió Exdoo en tu navegador (computadora o celular). Verás una página con un código QR.

  3. 3

    Vincula tu WhatsApp con el QR

    Funciona tanto con WhatsApp como con WhatsApp Business — el flujo es idéntico. En tu teléfono:

    1. Abre WhatsApp o WhatsApp Business.
    2. Toca el menú (Android) o Configuración (iPhone).
    3. Selecciona Dispositivos vinculados → Vincular un dispositivo.
    4. Apunta la cámara al código QR de la página.
    ¿Cuál usar? Si esperas mandar muchos mensajes al día (campañas, notificaciones masivas, atención a varios clientes), te recomendamos WhatsApp Business — está diseñado para uso comercial y reduce el riesgo de que WhatsApp marque tu número. Para uso ligero o personal, WhatsApp normal funciona igual.
  4. 4

    Espera la confirmación

    La página detecta el escaneo en pocos segundos y muestra ✅ WhatsApp conectado junto con tu número. A partir de ese momento, tu cuenta queda enlazada.

  5. 5

    ¡Ya puedes usar la API!

    Con tu token en mano, ya puedes hacer requests HTTP contra los endpoints documentados aquí abajo. Empieza por algo simple — un mensaje de prueba:

    curl -X POST https://messages.odoo.blue/v1/whatsapp/send-text \
      -H "Content-Type: application/json" \
      -d '{
        "token": "<TU_TOKEN>",
        "to": "521XXXXXXXXXX",
        "text": "¡Hola desde la API!"
      }'

    El número to es E.164 sin + (en México: 521 + 10 dígitos).

¿Perdiste tu token o tu número se desconectó? Escribe a Exdoo. Pueden revocar/reemitir tu token, y si solo necesitas reescanear el QR (por ejemplo cambiaste de celular), tu link de onboarding sigue funcionando — solo vuelve a abrirlo.

Autenticación

Todos los endpoints (excepto GET /health) requieren un campo token en el body JSON. El token resuelve la sesión a usar.

POST /v1/whatsapp/send HTTP/1.1
Host: messages.odoo.blue
Content-Type: application/json
X-API-Key: <opcional>

{ "token": "<TU_TOKEN>", ... }

Si la variable de entorno API_KEY está configurada en el servidor, hay que mandar además el header X-API-Key con su valor. Si no está configurada, ese header se ignora.

Rate limits

ConceptoValor
Rate limit30 requests/minuto por sesión (compartido entre envío y lectura)
Timeout upstream30 segundos
Reintentos en errores transitorios3 con backoff exponencial (1s → 2s → 4s)
Nota: el rate limit es por sesión, no por endpoint. Si una sesión envía 30 mensajes en un minuto, también queda bloqueada para listar chats o descargar media hasta que pase el minuto.

Errores

Forma general de un error:

{ "ok": false, "error": "descripción del problema" }
HTTPCausa
400JSON inválido, campo requerido faltante, formato inválido (teléfono, chat_id, message_id, mimetype no permitido).
401token inválido o X-API-Key incorrecta.
404Mensaje no existe (no se encontró en el chat indicado).
429Rate limit excedido para la sesión.
502El proveedor de mensajería no respondió o devolvió un error inesperado.

Conectar / arrancar sesión

POST /v1/sessions/connect

Arranca la sesión del cliente del lado del proveedor. Después de llamar esto, el estado se mueve a SCAN_QR_CODE y se puede pedir el QR con /sessions/qr. Idempotente: si ya está arrancando o funcionando, no falla.

Request

{ "token": "<TU_TOKEN>" }

Response 200

{
  "ok": true,
  "status": "SCAN_QR_CODE"
}

Estado de la sesión

POST /v1/sessions/status

Pollea esto durante el onboarding (cada 3-5 s) hasta que status === "WORKING". Cuando esté WORKING devuelve también el número conectado.

Request

{ "token": "<TU_TOKEN>" }

Response 200

{
  "ok": true,
  "status": "WORKING",
  "wa_number": "5215555555555",
  "push_name": "Maria - Acme"
}

Valores de status

StatusSignificado
STARTINGInicializando, espera 1-2 s.
SCAN_QR_CODEListo para escanear; usa /qr.
WORKINGConectado; ya puede enviar/leer.
FAILEDFalla del proveedor; reintenta /connect.
STOPPEDDetenida; llama a /connect para arrancar otra vez.

Obtener QR

POST /v1/sessions/qr

Devuelve el QR como PNG codificado en base64. Solo funciona cuando status es SCAN_QR_CODE. El QR rota aproximadamente cada 20 segundos — refrescarlo con esa cadencia.

Request

{ "token": "<TU_TOKEN>" }

Response 200

{
  "ok": true,
  "format": "png",
  "qr_b64": "iVBORw0KGgoAAAANSUhEUg..."
}

Errores

HTTPCaso
409La sesión no está en SCAN_QR_CODE (consulta /status primero).
404Sesión no existe en el proveedor (llama /connect primero).

Mostrar en HTML

const r = await fetch("/v1/sessions/qr", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ token })
});
const data = await r.json();
img.src = "data:image/png;base64," + data.qr_b64;

Cerrar sesión

POST /v1/sessions/logout

Desvincula el número de WhatsApp de la sesión. El token sigue siendo válido — el cliente puede reescanear su QR llamando a /connect + /qr de nuevo.

Request

{ "token": "<TU_TOKEN>" }

Response 200

{ "ok": true }

Página de onboarding

GET /onboard?token=<TOKEN>

Página web lista para usar que el cliente abre en su navegador. Muestra el QR, lo refresca automáticamente, polea el estado y muestra confirmación con el número conectado al terminar. No requiere que el cliente integre nada — solo abre el link.

Si abren /onboard sin ?token=, la página les pide pegar su token en un campo. El admin típicamente entrega un link directo con el token incrustado.

Flujo típico:
  1. Tu administrador del servicio te entrega un token + un link de onboarding.
  2. Abres el link en tu navegador y escaneas el QR con tu WhatsApp.
  3. Cuando la página muestra ✅, ya puedes usar los endpoints de envío/lectura con tu token.

Enviar texto y/o adjuntos

POST /v1/whatsapp/send

Endpoint principal de envío. Acepta un mensaje con plantilla ({{vars}}) y 0..N adjuntos. Si attachments está vacío o ausente, envía solo texto. Si hay adjuntos, el texto se manda como caption del primer adjunto.

Request

{
  "token": "<TU_TOKEN>",
  "to": "5215555555555",
  "message": {
    "template": "Hola {{nombre}}, tu factura {{folio}} está lista.",
    "vars": { "nombre": "Ana", "folio": "F-001" }
  },
  "attachments": [
    {
      "filename": "factura.pdf",
      "mimetype": "application/pdf",
      "content_b64": "JVBERi0xLjQK..."
    }
  ]
}
CampoTipoRequeridoNotas
tokenstringResuelve la sesión.
tostringE.164 sin + (10–15 dígitos). Acepta también con espacios/guiones — se normaliza.
message.templatestringTexto con variables {{nombre}}. Si no usas vars, manda el texto plano aquí.
message.varsobjectnoReemplazos para la plantilla.
attachmentsarraynoLista de adjuntos. Omitir o lista vacía = solo texto.
attachments[].filenamestringsí*Si la extensión no coincide con el mimetype, se agrega automáticamente.
attachments[].mimetypestringsí*Ver mimetypes permitidos abajo.
attachments[].content_b64stringsí*Base64 estándar. Acepta también data URLs (data:...;base64,).

Mimetypes permitidos

application/pdf · application/xml · text/xml · image/jpeg · image/png · image/gif · image/webp

Tip: las imágenes se envían como image (con preview inline en WhatsApp). Los PDF/XML como document.

Ejemplo curl

curl -X POST https://messages.odoo.blue/v1/whatsapp/send \
  -H "Content-Type: application/json" \
  -d '{
    "token": "<TU_TOKEN>",
    "to": "5215555555555",
    "message": {
      "template": "Hola {{nombre}}",
      "vars": { "nombre": "Ana" }
    },
    "attachments": []
  }'

Response 200 (solo texto)

{
  "ok": true,
  "mode": "text",
  "upstream": { /* respuesta cruda del proveedor (incluye id del mensaje) */ }
}

Response 200 (con adjuntos)

{
  "ok": true,
  "mode": "attachments",
  "count": 1,
  "results": [
    { "filename": "factura.pdf", "upstream": { /* ... */ } }
  ]
}

Enviar solo texto

POST /v1/whatsapp/send-text

Versión simplificada para mensajes de texto plano. No usa plantillas — el texto se manda tal cual.

Request

{
  "token": "<TU_TOKEN>",
  "to": "5215555555555",
  "text": "Mensaje sin variables, tal cual."
}

Ejemplo curl

curl -X POST https://messages.odoo.blue/v1/whatsapp/send-text \
  -H "Content-Type: application/json" \
  -d '{
    "token": "<TU_TOKEN>",
    "to": "5215555555555",
    "text": "Hola, prueba"
  }'

Response 200

{
  "ok": true,
  "mode": "text",
  "upstream": { /* respuesta cruda del proveedor */ }
}

Enviar un documento (legacy)

POST /v1/whatsapp/send-document
Legacy: mantenido por compatibilidad con integraciones anteriores. Internamente delega en /v1/whatsapp/send. Para código nuevo, usa /send directamente — soporta múltiples adjuntos.

Request

{
  "token": "<TU_TOKEN>",
  "to": "5215555555555",
  "message": {
    "template": "Aquí está tu factura {{folio}}.",
    "vars": { "folio": "F-001" }
  },
  "document": {
    "filename": "factura.pdf",
    "mimetype": "application/pdf",
    "content_b64": "JVBERi0xLjQK..."
  }
}

document.mimetype es opcional y por defecto es application/pdf.

Listar chats

POST /v1/whatsapp/chats

Devuelve los chats de la sesión, ordenados por última actividad (igual que la UI de WhatsApp). Ideal para construir un sidebar tipo WhatsApp Web.

Request

{
  "token": "<TU_TOKEN>",
  "limit": 50,
  "offset": 0
}
CampoTipoDefaultNotas
tokenstringRequerido.
limitint50Entre 1 y 200.
offsetint0Para paginar.

Response 200

{
  "ok": true,
  "limit": 50,
  "offset": 0,
  "chats": [
    {
      "chat_id": "5217772544205@c.us",
      "kind": "individual",
      "name": "Contabilidad Sambol",
      "phone": "5217772544205",
      "picture": "https://pps.whatsapp.net/...",
      "last_message": {
        "id": "true_226989390713028@lid_3EB02BD15B06950CB036BD",
        "timestamp": 1778624266,
        "from_me": true,
        "type": "text",
        "preview": "Natalia, ya notifiqué..."
      }
    }
  ]
}

Detalle de campos

CampoNotas
chat_idOpaco. Úsalo tal cual al pedir mensajes; no lo parsees.
kind"individual" o "group".
phoneSólo en chats individuales (10–15 dígitos). En grupos es null.
pictureURL de WhatsApp CDN. Puede ser null. Las URLs tienen TTL — no las guardes a largo plazo.
last_message.type"text", "image", "audio", "video", "document" u "other".
last_message.previewRecortado a 200 chars. Para media: [imagen], [audio], [video], [documento], [adjunto].
last_message.timestampUnix epoch en segundos.

Mensajes de un chat

POST /v1/whatsapp/chats/messages

Mensajes de un chat específico, ordenados por timestamp descendente (más reciente primero). No incluye los bytes de los adjuntos — para eso usa /chats/media.

Request

{
  "token": "<TU_TOKEN>",
  "chat_id": "5219999968561@c.us",
  "limit": 50,
  "offset": 0
}
CampoTipoDefaultNotas
tokenstringRequerido.
chat_idstringRequerido. Tal cual lo devolvió /chats.
limitint50Entre 1 y 200.
offsetint0Para paginar (scroll hacia atrás).

Response 200

{
  "ok": true,
  "chat_id": "5219999968561@c.us",
  "limit": 50,
  "offset": 0,
  "has_more": true,
  "messages": [
    {
      "id": "false_..._AC52E349...",
      "timestamp": 1778622898,
      "from_me": false,
      "author": null,
      "type": "text",
      "text": "Listo, ya pude aclararlo.",
      "media": null
    }
  ]
}

Detalle de campos

CampoNotas
idIdentificador del mensaje. Pásalo como message_id al endpoint de media.
from_metrue si la sesión es la que envió el mensaje.
authorEn grupos: número del miembro que envió. null en individuales.
type"text", "image", "audio", "video", "document" u "other".
textCuerpo o caption. null si es media sin texto.
medianull en texto puro. Si hay adjunto, media.media_id = lo que va a /chats/media.
has_moretrue si quedan más mensajes hacia atrás (incrementa offset).

Paginación (scroll infinito)

  1. Primera llamada: offset=0, limit=50.
  2. Al hacer scroll hacia arriba: offset=50, limit=50. Etc.
  3. Detener cuando has_more == false.

Descargar adjunto

POST /v1/whatsapp/chats/media

Descarga el archivo adjunto (imagen, audio, video, documento) de un mensaje específico y lo devuelve como base64. Pensado para llamar bajo demanda — sólo cuando el usuario abre el adjunto en la UI.

Request

{
  "token": "<TU_TOKEN>",
  "chat_id": "5219999968561@c.us",
  "message_id": "false_..._AC52E349..."
}
CampoTipoNotas
tokenstringRequerido.
chat_idstringRequerido. El chat al que pertenece el mensaje.
message_idstringRequerido. Debe empezar con true_ o false_.

Response 200

{
  "ok": true,
  "filename": "AC52E349153505DE4FFCA863C11B8BAC.jpeg",
  "mimetype": "image/jpeg",
  "size": 37701,
  "content_b64": "/9j/4AAQSkZJRgABAQAAAQABAAD..."
}

Errores específicos

HTTPCaso
400message_id mal formado o el mensaje no tiene media (es texto puro).
404chat_id+message_id no corresponde a ningún mensaje.
502No se pudo descargar el archivo del proveedor upstream.

Decodificar a archivo (Python)

import base64, requests

r = requests.post(
    "https://messages.odoo.blue/v1/whatsapp/chats/media",
    json={
        "token": "<TU_TOKEN>",
        "chat_id": "5219999968561@c.us",
        "message_id": "false_..._AC52E349...",
    },
)
data = r.json()
with open(data["filename"], "wb") as f:
    f.write(base64.b64decode(data["content_b64"]))

Guardar como ir.attachment (Odoo)

attachment = self.env["ir.attachment"].create({
    "name": data["filename"],
    "datas": data["content_b64"],     # Odoo guarda base64 directo
    "mimetype": data["mimetype"],
    "res_model": "whatsapp.message",
    "res_id": record.id,
})

Webhooks NUEVO

En vez de hacer polling, registra una URL pública y el servicio te empuja cada mensaje entrante en tiempo real — firmado con HMAC, deduplicado y con reintentos automáticos.

Cómo funcionan

Cada evento que recibe la sesión vinculada a tu token se encola y un worker en background lo entrega a tu endpoint con una firma X-Signature-256. Tu endpoint solo tiene que validar la firma y responder 2xx.

WhatsApp → Exdoo Messaging → outbox (cola)
                  │
                  │  worker en background
                  ▼
            HMAC-SHA256 sign
                  │
                  ▼  POST con X-Signature-256
            Tu endpoint público
                  │
                  ▼  responde 2xx
            marca delivered

Registrar subscripción

El registro lo hace el equipo de Exdoo (endpoint admin). De tu lado necesitamos:

  • Una URL pública alcanzable desde internet (HTTPS preferentemente).
  • Una ruta que reciba POST application/json (p.ej. POST /whatsapp/inbound en tu Odoo).
  • El token de la sesión cuya conversación quieres escuchar.
POST /v1/admin/webhooks admin

Payload

{
  "token": "Bd8KkH...",
  "webhook_url": "https://tu-dominio.mx/whatsapp/inbound",
  "events": "message,message.ack",
  "label": "comercialwhitehouse"
}
  • events es una lista CSV; default message,message.ack.
  • Si la subscripción ya existe para ese token, se actualiza (no se cambia el secret).

Response 201 — incluye el secret HMAC

{
  "ok": true,
  "subscription": {
    "id": 1,
    "token": "Bd8KkH...",
    "session_name": "cust_a1b2c3d4",
    "webhook_url": "https://tu-dominio.mx/whatsapp/inbound",
    "secret_hmac": "QGQjhlxVETJTga-2fcHmG4t9TQFKRDWvKEKJiMbnyv8",
    "events": "message,message.ack",
    "active": 1,
    "label": "comercialwhitehouse",
    "created_at": 1780691879,
    "updated_at": 1780691879
  }
}
Guarda el secret_hmac: se entrega solo aquí y al rotarlo. Ponlo en una variable de entorno de tu lado. Si se pierde, hay que rotar.

Formato del POST que recibes

Cuando entra un mensaje, tu endpoint recibe headers de control + el body JSON:

POST https://tu-dominio.mx/whatsapp/inbound
Content-Type: application/json
X-Event-Id: 42
X-Event-Type: message
X-Session: cust_a1b2c3d4
X-Timestamp: 1780692000
X-Signature-256: sha256=abc123def456...

{
  "event": "message",
  "session": "cust_a1b2c3d4",
  "message_id": "false_5217711234567@c.us_3EB0AABCDEF",
  "chat_id": "5217711234567@c.us",
  "from": "5217711234567",
  "from_me": false,
  "timestamp": 1717612345,
  "type": "text",
  "text": "Hola, quería preguntar...",
  "media": null
}

Mensaje con media

{
  "event": "message",
  "type": "image",
  "text": "Foto del recibo",
  "media": {
    "media_id": "false_..._XYZ",
    "mimetype": "image/jpeg",
    "filename": null,
    "size": 184320
  }
}

Para descargar el archivo, usa POST /v1/whatsapp/chats/media con el chat_id y el message_id.

Cambio de estado (ack)

{
  "event": "message.ack",
  "session": "cust_a1b2c3d4",
  "message_id": "true_..._XYZ",
  "ack": 3,
  "timestamp": 1717612500
}
ackSignificado
1Enviado al servidor de WhatsApp.
2Entregado al dispositivo destino.
3Leído por el destinatario.
4Audio reproducido (solo mensajes de voz).

Validar la firma HMAC

Antes de procesar el body, valida que X-Signature-256 coincida con un HMAC-SHA256 calculado sobre {X-Timestamp}.{body} usando tu secret_hmac como key.

Python (Odoo)

import hmac, hashlib

def verificar_firma(headers, raw_body_bytes, secret):
    ts  = headers.get('X-Timestamp', '')
    sig = headers.get('X-Signature-256', '')
    msg = f"{ts}.".encode() + raw_body_bytes
    expected = "sha256=" + hmac.new(
        secret.encode(), msg, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, sig)

# En el handler:
if not verificar_firma(request.headers, request.httprequest.get_data(), SECRET):
    return Response('forbidden', status=401)

Node.js

const crypto = require('crypto');

function verificarFirma(headers, rawBody, secret) {
  const ts  = headers['x-timestamp'] || '';
  const sig = headers['x-signature-256'] || '';
  const msg = Buffer.concat([Buffer.from(ts + '.'), rawBody]);
  const expected = 'sha256=' +
    crypto.createHmac('sha256', secret).update(msg).digest('hex');
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig));
}
Anti-replay: si X-Timestamp está a más de unos minutos del reloj actual, rechaza el request. Tolerancia recomendada: ±5 min.

Reintentos e idempotencia

Si tu endpoint responde algo distinto de 2xx (o no responde en 10 s), el evento vuelve a la cola con backoff:

IntentoEspera antes del siguiente
1 falla30 s
2 falla2 min
3 falla10 min
4 falla30 min
5 falla1 h
6 falla4 h
7+ falla12 h, luego abandoned

Tras 6 intentos fallidos el evento queda abandoned. Sigue siendo consultable y se puede re-encolar manualmente (ver Administración).

Idempotencia: cada evento tiene un event_uid determinista (sha1("{event_type}|{message_id}|{ack_or_0}")); Exdoo deduplica reenvíos del upstream. Aun así, haz tu handler idempotente usando X-Event-Id o message_id como clave.

Health check

GET /health

Verifica que el servicio responda y que pueda alcanzar el proveedor upstream. No requiere autenticación.

Ejemplo

curl https://messages.odoo.blue/health

Response 200

{
  "ok": true,
  "service": "exdoo-messaging",
  "upstream_reachable": true,
  "sessions_count": 4
}

Si el upstream no está alcanzable, devuelve 503 con "upstream_reachable": false.

Flujo típico — UI estilo WhatsApp Web

  1. Al cargar la app: POST /v1/whatsapp/chats → llenar sidebar de conversaciones.
  2. Al clickear un chat: POST /v1/whatsapp/chats/messages con su chat_id → panel de mensajes.
  3. Al scrollear hacia arriba: misma llamada con offset += limit hasta has_more=false.
  4. Al clickear un adjunto: POST /v1/whatsapp/chats/media con chat_id + message_id.
  5. Al enviar: POST /v1/whatsapp/send con texto y/o adjuntos.
Recordatorios:
  • Estos endpoints son de lectura — no marcan como leído en WhatsApp.
  • El rate limit (30/min) es por sesión y compartido entre todos los endpoints.
  • Las URLs de foto de perfil expiran (TTL de WhatsApp CDN); refrésca llamando a /chats de nuevo.
  • type: "other" en un mensaje significa que el media aún no se ha descargado en el servidor. Llamar a /chats/media "forza" la descarga.

Administración

Solo equipo Exdoo. Estos endpoints están protegidos por el header X-Admin-Key y no son para clientes finales. Se documentan aquí como referencia interna.

Tokens

POST /v1/admin/tokens admin

Crea un nuevo token de cliente + su sesión upstream asociada.

Payload

{ "label": "comercialwhitehouse" }

Response 201

{
  "ok": true,
  "id": "0a1b2c3d4e5f6789...",
  "token": "Bd8KkH...secreto largo",
  "label": "comercialwhitehouse",
  "created_at": "2026-06-05T20:00:00Z",
  "onboard_url": "/onboard?token=Bd8KkH..."
}

El token se devuelve una sola vez; el servicio no lo expone más.

GET /v1/admin/tokens admin

Lista los tokens dinámicos (no incluye los hardcoded del proveedor).

{
  "ok": true,
  "tokens": [
    {
      "id": "0a1b2c3d...",
      "session_name": "cust_a1b2c3d4e5f6",
      "label": "comercialwhitehouse",
      "wa_number": "5217711234567",
      "bound": 1,
      "created_at": "2026-06-05T20:00:00Z",
      "last_seen_at": "2026-06-05T22:30:00Z"
    }
  ]
}
DELETE /v1/admin/tokens/<id> admin

Revoca un token, cierra su sesión upstream y elimina el recurso (best-effort).

{ "ok": true, "deleted": "0a1b2c3d...", "session_name": "cust_a1b2c3d4e5f6" }

Subscripciones de webhooks

GET /v1/admin/webhooks admin

Lista todas las subscripciones (sin exponer secret_hmac).

GET /v1/admin/webhooks/<token> admin

Detalle de una subscripción.

DELETE /v1/admin/webhooks/<token> admin

Elimina la subscripción y, en cascada, sus eventos pendientes.

POST /v1/admin/webhooks/<token>/rotate-secret admin

Genera un nuevo secret_hmac; el anterior queda inutilizable de inmediato.

GET /v1/admin/webhooks/<token>/events admin

Auditoría de entregas. Query: status (pending/in_flight/delivered/abandoned), limit (default 50, máx 500).

{
  "ok": true,
  "events": [
    {
      "id": 1,
      "event_type": "message",
      "status": "delivered",
      "attempts": 1,
      "next_retry_at": 1717612345,
      "last_error": null,
      "delivered_at": 1717612346,
      "created_at": 1717612345
    }
  ]
}
POST /v1/admin/webhooks/events/<event_id>/retry admin

Re-encola un evento failed o abandoned para reintento inmediato.