Skip to content

Webhooks normalizados

La Kalipto Devs Platform ofrece dos modos para recibir eventos de tu tráfico de WhatsApp e Instagram:

ModoCómo se configuraContenidoCuá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.

EventoFuenteCuándo se emite
message.receivedservices/platform_persistence._insert_message (inbound)Mensaje entrante persistido. Excluido del wildcard catch-all (alto volumen) — sub explícita para recibirlo.
message.sentservices/platform_persistence.persist_outboundTu POST /platform/v1/messages se enviado a Meta exitosamente.
message.deliveredservices/platform_persistence._apply_statusMeta confirmó entrega al device del usuario.
message.readservices/platform_persistence._apply_statusEl usuario leyó el mensaje (read receipt).
message.failedservices/platform_persistence._apply_statusMeta rechazó la entrega (incluye el meta_error envelope en el payload).
message.echoservices/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.createdservices/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.endedPATCH /platform/v1/conversations/{id} (status=ended)Tu equipo o tu integración cerró la conversación.
conversation.inactiveplatform_conversation_inactive_sweep cron (cada 60s)La conversación no tiene actividad hace más de inactivity_minutes minutos (configurado por sub).
channel.connectedservices/platform_channel_register.register_platform_channel post-commitSe completó la conexión de un canal nuevo (WhatsApp Embedded Signup o Instagram Business Login OAuth).
channel.disconnectedapi/platform/channels.py DELETE /{id} post-commitEl developer desconectó un canal.
template.status_changed (v399)services/platform_status_ingest.py, después del commit de un callback de MetaMeta 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 excepto message.received (es de muy alto volumen). Para recibirlo, listalo explícitamente. El ÚNICO evento wildcard-excluido es message.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_changed tampoco 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.

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" }
}
CampoDetalle
idevt_<uuid>idempotency key para vos. Los retries usan el MISMO id. Almacenalo y descartá duplicados si lo ves dos veces.
eventUno de los 12 tipos de la tabla de arriba.
developer_account_idTu cuenta de developer (mismo id que ves en GET /platform/dashboard/me).
platform_channel_idEl canal que originó el evento. null en suscripciones account-level cuando el evento no tiene contexto de canal específico.
occurred_atISO8601 UTC.
api_version"platform-v1" — fijo hoy. Wire-shape contract.
dataPayload específico por evento (ver siguiente sección).
// 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 el meta_identifier completo (phone_number_id / ig_account_id) en los eventos channel.* para evitar enumeración cross-tenant. Solo los últimos 4 chars, suficiente para identificar visualmente el canal en un log.

Terminal window
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 secret se 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-secret para generar uno nuevo.

CampoDetalle
target_urlTu HTTPS receiver. SSRF-validado: HTTPS only, IPs públicas (rechaza private / loopback / link-local / reserved / metadata). Re-validado en cada PATCH.
platform_channel_idnull = account-level (matchea cualquier canal de la cuenta). Number = scoped a ese canal.
event_typesLista de eventos a recibir. [] = todos excepto WILDCARD_EXCLUDED_EVENTS (message.received).
custom_headersHeaders 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_secondsnull = entrega inmediata. 1..60 = bufferea events de cada event_type por X segundos antes de entregar en batch.
buffer_max_batch1..100. Si el buffer junta este número antes de que expire la ventana, flushea anticipado.
inactivity_minutes1..1440. Si una conversación pasa este tiempo sin actividad, dispará conversation.inactive. null = no emitir inactives para esta sub.
is_activefalse para “pausar manualmente” sin borrar.

Cada POST que recibís lleva:

Content-Type: application/json
User-Agent: Kalipto-Platform/1.0
X-Kalipto-Event: message.received
X-Kalipto-Event-Id: evt_<32-hex> # mismo que body.id; idempotency key
X-Kalipto-Delivery-Id: <int> # id de platform_event_deliveries; mismo en cada retry
X-Kalipto-Signature: sha256=<hex> # HMAC-SHA256 sobre el body raw bytes

Tu handler debe verificar el HMAC contra el secret plaintext que almacenaste en el create:

import hmac
import 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.

Terminal window
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_type debe estar en PLATFORM_KNOWN_EVENT_TYPES (422 en typo).
  • Si tu sub tiene event_types explícitos, el sample debe ser uno de ellos (422 event_not_configured, la respuesta incluye los event_types configurados).
  • Si tu sub es catch-all (event_types: []), acepta cualquier evento — incluso los wildcard-excluded para que puedas testear el wire.

Si tu URL devuelve non-2xx o no responde:

IntentoDelay desde el anterior
1inmediato
21 min
35 min
430 min
5120 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.

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:

Terminal window
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).

Ú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:

  1. Acumula envelopes en una lista Redis interna.
  2. Espera hasta que pasen 10 segundos desde el primer evento de la ventana O la lista llegue a 50 envelopes.
  3. Persiste una sola row de delivery con payload = { events: [env1, env2, …] }.
  4. 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)

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.

Terminal window
# Ú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 24h
curl "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 queue
curl "https://api.kalipto.app/platform/v1/webhooks/deliveries?mode=meta" \
-H "X-API-Key: $KALIPTO_KEY"

Filtros (todos opcionales, AND-combined):

ParámetroModosDetalle
modekalipto (default) / metakalipto lee platform_event_deliveries (eventos normalizados); meta lee platform_deliveries (forward crudo del raw mode).
subscription_idkalipto onlyFiltrar por una suscripción específica.
channel_idambosFiltrar por canal.
statusambospending / delivering / delivered / failed.
event_typekalipto onlyEj. message.received.
errors_onlyambosAtajo equivalente a status=failed.
after / beforeambosCursor de paginación.
limitambos1..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:

Terminal window
curl "https://api.kalipto.app/platform/v1/webhooks/deliveries/1234" \
-H "X-API-Key: $KALIPTO_KEY"

El modo meta (raw platform_deliveries) no tiene endpoint de detalle por delivery individual — solo el listado. El forward crudo no tiene subscription_id ni event_type (es un forward 1-a-1 per canal), esos filtros se ignoran silenciosamente cuando mode=meta.

Terminal window
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.

Terminal window
# Actualizar parcialmente
curl -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á PATCH con is_active: false.