Webhooks normalizados
La Kalipto Devs Platform ofrece dos modos para recibir eventos de tu tráfico de WhatsApp e Instagram:
| Modo | Cómo se configura | Contenido | Cuándo elegirlo |
|---|---|---|---|
meta (raw forward) | PATCH /platform/dashboard/channels/{id}/webhook (1 URL por canal) | El payload Meta verbatim re-firmado con tu secret del canal. Ver Webhook entrante (forward). | Cuando ya tenés código que parsea payloads Meta y querés migrar de tu propia integración Meta a Kalipto sin reescribir el handler. |
kalipto (normalizado) | POST /platform/v1/webhooks/subscriptions (múltiples URLs por cuenta o canal) | Eventos tipados con un envelope estable de Kalipto ({id, event, developer_account_id, platform_channel_id, occurred_at, api_version, data}). | Cuando arrancás desde cero y querés un wire format consistente y filtrable; o cuando querés suscripciones distintas por tipo de evento. |
Ambos modos coexisten. El raw forward se sigue activando per-canal vía
PATCH /channels/{id}/webhook; el modo normalizado vive bajo
/platform/v1/webhooks/*.
Todas las rutas /platform/v1/webhooks/* requieren manage:webhooks. El
scope es accesible desde una API key (server-to-server) o desde una sesión
cookie del dashboard.
Eventos disponibles (12 tipos)
Section titled “Eventos disponibles (12 tipos)”| Evento | Fuente | Cuándo se emite |
|---|---|---|
message.received | services/platform_persistence._insert_message (inbound) | Mensaje entrante persistido. Excluido del wildcard catch-all (alto volumen) — sub explícita para recibirlo. |
message.sent | services/platform_persistence.persist_outbound | Tu POST /platform/v1/messages se enviado a Meta exitosamente. |
message.delivered | services/platform_persistence._apply_status | Meta confirmó entrega al device del usuario. |
message.read | services/platform_persistence._apply_status | El usuario leyó el mensaje (read receipt). |
message.failed | services/platform_persistence._apply_status | Meta rechazó la entrega (incluye el meta_error envelope en el payload). |
message.echo | services/platform_persistence._persist_wa (branch smb_message_echoes) | El eco del mensaje que el dueño del canal escribió desde la app de WhatsApp Business (WhatsApp Coexistence). Solo WhatsApp. NO excluido del catch-all (bajo volumen, alta señal): una sub event_types: [] lo recibe. En el payload, data.source vale app_echo. |
conversation.created | services/platform_persistence._upsert_conversation (branch new-row) | Se abre una conversación nueva. Pasa cuando un peer escribe por primera vez, o (WF v325) cuando el dueño arranca el chat desde la app de WhatsApp Business (el primer eco del dueño también abre la conversación). |
conversation.ended | PATCH /platform/v1/conversations/{id} (status=ended) | Tu equipo o tu integración cerró la conversación. |
conversation.inactive | platform_conversation_inactive_sweep cron (cada 60s) | La conversación no tiene actividad hace más de inactivity_minutes minutos (configurado por sub). |
channel.connected | services/platform_channel_register.register_platform_channel post-commit | Se completó la conexión de un canal nuevo (WhatsApp Embedded Signup o Instagram Business Login OAuth). |
channel.disconnected | api/platform/channels.py DELETE /{id} post-commit | El developer desconectó un canal. |
template.status_changed (v399) | services/platform_status_ingest.py, después del commit de un callback de Meta | Meta aprobó, rechazó, o cambió el status de UNO de tus templates. Se resuelve como máximo una fila local por callback — nunca hace bulk-update de todos los idiomas de un mismo nombre. Se emite solo cuando el status realmente cambió (un callback duplicado con el mismo status no dispara un segundo evento). |
Wildcard exclusion: una sub con
event_types: [](catch-all) recibe todos los eventos exceptomessage.received(es de muy alto volumen). Para recibirlo, listalo explícitamente. El ÚNICO evento wildcard-excluido esmessage.received:message.echo(el eco del dueño) NO está excluido, así que una sub catch-all lo recibe sin tener que listarlo (es de bajo volumen).template.status_changedtampoco está excluido — es de bajo volumen (una fila cambia de status ocasionalmente) y es justo el tipo de evento que una sub catch-all quiere recibir.
Envelope shape
Section titled “Envelope shape”Cada POST de Kalipto a tu URL lleva un JSON con esta estructura:
{ "id": "evt_<32-hex>", "event": "message.received", "developer_account_id": 12, "platform_channel_id": 34, "occurred_at": "2026-06-11T13:45:22Z", "api_version": "platform-v1", "data": { "...": "event-specific payload" }}| Campo | Detalle |
|---|---|
id | evt_<uuid> — idempotency key para vos. Los retries usan el MISMO id. Almacenalo y descartá duplicados si lo ves dos veces. |
event | Uno de los 12 tipos de la tabla de arriba. |
developer_account_id | Tu cuenta de developer (mismo id que ves en GET /platform/dashboard/me). |
platform_channel_id | El canal que originó el evento. null en suscripciones account-level cuando el evento no tiene contexto de canal específico. |
occurred_at | ISO8601 UTC. |
api_version | "platform-v1" — fijo hoy. Wire-shape contract. |
data | Payload específico por evento (ver siguiente sección). |
Payload data por evento
Section titled “Payload data por evento”// message.* events{ message_id: number, conversation_id: number, peer_id: string, direction: "inbound" | "outbound", source: "contact" | "api" | "app_echo", // WF v325: origen del mensaje type: string, // text, image, video, ... text: string | null, has_media: boolean, media_id: string | null, status: string | null, // sent | delivered | read | failed (outbound) meta_message_id: string | null // wamid / IG mid}
// conversation.* events{ conversation_id: number, peer_id: string, status: "active" | "ended", message_count: number, last_active_at: string | null // ISO8601}
// channel.* events{ channel_id: number, channel_type: "whatsapp" | "instagram", meta_identifier_suffix: string // ÚLTIMOS 4 chars del meta_identifier — nunca el id completo}
// template.status_changed (v399){ template_id: number, name: string, language_code: string, previous_status: string, // "pending" | "approved" | "rejected" | ... status: string, // el nuevo status meta_template_id: string | null, // siempre en forma string cuando está presente rejection_reason: string | null // null cuando el nuevo status es "approved" (se limpia si había uno previo)}
meta_identifier_suffix— Kalipto nunca incluye elmeta_identifiercompleto (phone_number_id/ig_account_id) en los eventoschannel.*para evitar enumeración cross-tenant. Solo los últimos 4 chars, suficiente para identificar visualmente el canal en un log.
Crear una suscripción
Section titled “Crear una suscripción”curl -X POST https://api.kalipto.app/platform/v1/webhooks/subscriptions \ -H "X-API-Key: $KALIPTO_KEY" \ -H "Content-Type: application/json" \ -d '{ "target_url": "https://miapp.com/kalipto/hook", "platform_channel_id": null, "event_types": ["message.received", "message.sent", "conversation.ended"], "description": "Production hook", "buffer_window_seconds": null, "buffer_max_batch": null, "inactivity_minutes": null, "custom_headers": { "X-Tenant": "abc-123" } }'Respuesta (201):
{ "id": 7, "developer_account_id": 12, "platform_channel_id": null, "target_url": "https://miapp.com/kalipto/hook", "description": "Production hook", "event_types": ["message.received", "message.sent", "conversation.ended"], "custom_headers": { "X-Tenant": "abc-123" }, "buffer_window_seconds": null, "buffer_max_batch": null, "inactivity_minutes": null, "is_active": true, "is_paused": false, "paused_reason": null, "failure_count": 0, "last_delivery_at": null, "created_at": "2026-06-11T14:00:00", "updated_at": "2026-06-11T14:00:00", "secret": "whsec_<32-byte-url-safe-plaintext>"}El
secretse devuelve UNA SOLA VEZ — almacénalo seguro AHORA. Después de este response, no hay forma de recuperarlo (el row guarda solo la versión Fernet-encriptada). Si lo perdés, usáPOST /subscriptions/{id}/rotate-secretpara generar uno nuevo.
| Campo | Detalle |
|---|---|
target_url | Tu HTTPS receiver. SSRF-validado: HTTPS only, IPs públicas (rechaza private / loopback / link-local / reserved / metadata). Re-validado en cada PATCH. |
platform_channel_id | null = account-level (matchea cualquier canal de la cuenta). Number = scoped a ese canal. |
event_types | Lista de eventos a recibir. [] = todos excepto WILDCARD_EXCLUDED_EVENTS (message.received). |
custom_headers | Headers extra forwardeados verbatim. Los reservados (X-Kalipto-Signature, X-Kalipto-Event, X-Kalipto-Event-Id, X-Kalipto-Delivery-Id, Content-Type, Authorization) se strippan case-insensitive en el create (defense-in-depth). |
buffer_window_seconds | null = entrega inmediata. 1..60 = bufferea events de cada event_type por X segundos antes de entregar en batch. |
buffer_max_batch | 1..100. Si el buffer junta este número antes de que expire la ventana, flushea anticipado. |
inactivity_minutes | 1..1440. Si una conversación pasa este tiempo sin actividad, dispará conversation.inactive. null = no emitir inactives para esta sub. |
is_active | false para “pausar manualmente” sin borrar. |
Verificar la firma HMAC
Section titled “Verificar la firma HMAC”Cada POST que recibís lleva:
Content-Type: application/jsonUser-Agent: Kalipto-Platform/1.0X-Kalipto-Event: message.receivedX-Kalipto-Event-Id: evt_<32-hex> # mismo que body.id; idempotency keyX-Kalipto-Delivery-Id: <int> # id de platform_event_deliveries; mismo en cada retryX-Kalipto-Signature: sha256=<hex> # HMAC-SHA256 sobre el body raw bytesTu handler debe verificar el HMAC contra el secret plaintext que almacenaste en el create:
import hmacimport hashlib
def verify(body_bytes: bytes, header: str, secret: str) -> bool: expected = "sha256=" + hmac.new( secret.encode(), body_bytes, hashlib.sha256 ).hexdigest() return hmac.compare_digest(expected, header)Ver Verificación HMAC para snippets en Node, Go, PHP.
Comparación constante en el tiempo: usá siempre
hmac.compare_digest(Python),crypto.timingSafeEqual(Node),hmac.Equal(Go). Una comparación==simple es vulnerable a timing attacks.
Probar la integración con /test
Section titled “Probar la integración con /test”curl -X POST "https://api.kalipto.app/platform/v1/webhooks/subscriptions/7/test?event_type=message.received" \ -H "X-API-Key: $KALIPTO_KEY"Encola un envelope firmado de prueba (con data._test: true) y lo entrega
por el mismo pipeline que un evento real. Útil para validar:
- Que tu URL es alcanzable.
- Que tu verificación HMAC funciona.
- Que parseás el envelope correctamente.
Validaciones:
event_typedebe estar enPLATFORM_KNOWN_EVENT_TYPES(422 en typo).- Si tu sub tiene
event_typesexplícitos, el sample debe ser uno de ellos (422event_not_configured, la respuesta incluye losevent_typesconfigurados). - Si tu sub es catch-all (
event_types: []), acepta cualquier evento — incluso los wildcard-excluded para que puedas testear el wire.
Retry schedule
Section titled “Retry schedule”Si tu URL devuelve non-2xx o no responde:
| Intento | Delay desde el anterior |
|---|---|
| 1 | inmediato |
| 2 | 1 min |
| 3 | 5 min |
| 4 | 30 min |
| 5 | 120 min |
Después del 5to intento fallido, la entrega se marca failed (dead-letter).
El X-Kalipto-Event-Id se repite en cada intento — usalo como idempotency
key para descartar duplicados.
Auto-pause
Section titled “Auto-pause”Si tu sub acumula 20+ entregas en una ventana de 15 min con 10+ failures
Y fail_rate >= 85%, Kalipto la pausa automáticamente
(is_paused: true, paused_reason: "auto_paused_high_failure_rate"). El
emitter saltea las suscripciones pausadas, así no seguimos generando
deliveries que sabemos van a fallar.
Para reanudar después de arreglar tu endpoint:
curl -X POST https://api.kalipto.app/platform/v1/webhooks/subscriptions/7/resume \ -H "X-API-Key: $KALIPTO_KEY"Limpia is_paused + paused_reason + resetea failure_count a 0.
Idempotente — llamarlo sobre una sub no-pausada es no-op (igual 200).
Buffering — agrupar eventos en batches
Section titled “Buffering — agrupar eventos en batches”Útil para alta frecuencia (ej. múltiples message.sent rápidos):
{ "target_url": "https://miapp.com/kalipto/hook", "event_types": ["message.sent"], "buffer_window_seconds": 10, "buffer_max_batch": 50}Con esta config, el emitter:
- Acumula envelopes en una lista Redis interna.
- Espera hasta que pasen 10 segundos desde el primer evento de la ventana O la lista llegue a 50 envelopes.
- Persiste una sola row de delivery con
payload = { events: [env1, env2, …] }. - Entrega ese batch como un solo POST a tu URL.
Tu handler debe estar preparado para parsear ambas shapes — un envelope
individual {id, event, data, …} o un batch {events: [{…}, {…}]}. Si la
top-level key events está presente, asumí batch:
body = json.loads(request.body)if "events" in body: for env in body["events"]: process(env)else: process(body)Per-conversation ordering
Section titled “Per-conversation ordering”Para los eventos message.* que llevan conversation_peer, Kalipto serializa
las entregas per (subscription, conversation_peer) — si tenés una
entrega pendiente o en-vuelo para (sub=7, peer="59891234567"), la
siguiente para el mismo peer espera a que termine. Esto garantiza orden
para los receivers que asumen message.received antes que su message.read.
30s stuck-lease release: si una entrega lleva más de 30s en estado
delivering(ej. tu handler timeout-ea sin cerrar la conexión), Kalipto la considera “stuck” y libera la siguiente. Esto evita que un handler colgado bloquee el stream para esa conversación.
Los eventos channel.* y conversation.* (no-message) no usan
conversation_peer (es null) — no participan del ordering, se entregan
independientemente.
Observabilidad (/deliveries)
Section titled “Observabilidad (/deliveries)”# Últimas 50 entregas normalizadas (kalipto-mode, default)curl "https://api.kalipto.app/platform/v1/webhooks/deliveries?limit=50" \ -H "X-API-Key: $KALIPTO_KEY"
# Solo errores de una sub específica en las últimas 24hcurl "https://api.kalipto.app/platform/v1/webhooks/deliveries?subscription_id=7&status=failed&errors_only=true" \ -H "X-API-Key: $KALIPTO_KEY"
# Mismo endpoint, pero leyendo el raw forward queuecurl "https://api.kalipto.app/platform/v1/webhooks/deliveries?mode=meta" \ -H "X-API-Key: $KALIPTO_KEY"Filtros (todos opcionales, AND-combined):
| Parámetro | Modos | Detalle |
|---|---|---|
mode | kalipto (default) / meta | kalipto lee platform_event_deliveries (eventos normalizados); meta lee platform_deliveries (forward crudo del raw mode). |
subscription_id | kalipto only | Filtrar por una suscripción específica. |
channel_id | ambos | Filtrar por canal. |
status | ambos | pending / delivering / delivered / failed. |
event_type | kalipto only | Ej. message.received. |
errors_only | ambos | Atajo equivalente a status=failed. |
after / before | ambos | Cursor de paginación. |
limit | ambos | 1..200 (default 50). |
Respuesta:
{ "mode": "kalipto", "data": [ { "id": 1234, "subscription_id": 7, "developer_account_id": 12, "platform_channel_id": 34, "event_id": "evt_abc...", "event_type": "message.received", "conversation_peer": "59891234567", "status": "delivered", "attempts": 1, "next_retry_at": null, "response_status": 200, "response_body_snippet": null, "created_at": "2026-06-11T14:01:23", "delivered_at": "2026-06-11T14:01:23" } ], "next_cursor": "..", "prev_cursor": null}Para detalle de UNA delivery normalizada:
curl "https://api.kalipto.app/platform/v1/webhooks/deliveries/1234" \ -H "X-API-Key: $KALIPTO_KEY"El modo
meta(rawplatform_deliveries) no tiene endpoint de detalle por delivery individual — solo el listado. El forward crudo no tienesubscription_idnievent_type(es un forward 1-a-1 per canal), esos filtros se ignoran silenciosamente cuandomode=meta.
Rotar el HMAC secret
Section titled “Rotar el HMAC secret”curl -X POST https://api.kalipto.app/platform/v1/webhooks/subscriptions/7/rotate-secret \ -H "X-API-Key: $KALIPTO_KEY"Devuelve el row completo con un nuevo secret plaintext (mostrado una
sola vez). Las deliveries que ya estaban en vuelo con el secret viejo
siguen usando el viejo (la firma se calcula al momento del dispatch);
solo las deliveries persistidas DESPUÉS de la rotación usan el nuevo
secret.
Best-effort cutover — tu handler debe aceptar AMBOS secrets durante la ventana de rotación (típicamente unos pocos minutos) para evitar rechazar deliveries legítimas en vuelo.
Update + Delete
Section titled “Update + Delete”# Actualizar parcialmentecurl -X PATCH https://api.kalipto.app/platform/v1/webhooks/subscriptions/7 \ -H "X-API-Key: $KALIPTO_KEY" \ -H "Content-Type: application/json" \ -d '{ "is_active": false }'
# Hard-delete (CASCADE: borra todas las deliveries históricas)curl -X DELETE https://api.kalipto.app/platform/v1/webhooks/subscriptions/7 \ -H "X-API-Key: $KALIPTO_KEY"El DELETE es hard-delete con CASCADE: las deliveries históricas también desaparecen. Si querés “pausar” sin perder la historia, usá
PATCHconis_active: false.
Próximos pasos
Section titled “Próximos pasos”- Verificación HMAC — snippets en Node, Go, PHP.
- Webhook entrante (forward) — el otro modo (raw Meta payload).
- Data plane — leer mensajes / conversaciones / contactos persistidos por Kalipto.
- Templates — el ciclo de vida completo que dispara
template.status_changed.