Skip to main content

Events API

POST /api/public/events/ingest es la única vía por la que cualquier sistema (vivla-backend, externos, vivla-tools internos) introduce eventos de dominio en el pipeline de notificaciones. Características:
  • Envelope v1: response es { object: 'event', id, type, status, created_at }.
  • Idempotencia por Idempotency-Key header — repeticiones devuelven mismo id con status: 'idempotent'.
  • Validación Zod estricta por event_type. Tipos no registrados → 400 event_type_unknown.
  • Rate limit 200 req/min por IP, exponiendo headers X-RateLimit-*.
  • Logs estructurados y métricas PostHog.
Para consumir el catálogo de tipos en código, ver apps/backend/src/notifications/engine/schemas/event-schemas.ts (Zod schemas — fuente de verdad).

Request

Nota v3.1: el body field event_id fue eliminado. La idempotencia se hace siempre vía header Idempotency-Key.

User identity

Los payloads que referencian usuarios (userId, ownerId, guestId, target en actions, etc.) deben usar users.id de vivla-tools (UUID v4 de Supabase), NO el Firebase UID. El engine resuelve recipients via:
Si la query no devuelve nada, el engine intenta un fallback por vivla_user_id (Firebase UID) y logea un warning para que el emisor surface en métricas. Esto sirve solo de safety net mientras vivla-backend migra desde el legacy Firebase UID — no es un contrato estable. Los integradores deben enviar el UUID Supabase. Cómo obtener el users.id correcto desde vivla-backend:
  • Si tu sistema ya tiene el Firebase UID, mapealo via SELECT id FROM users WHERE vivla_user_id = $1 antes de emitir.
  • Lo ideal a mediano plazo es que vivla-backend reciba el UUID Supabase en su propio modelo (ya sea propagándolo desde vivla-tools al crear usuarios, o consultando vivla-tools en lookup).
Por qué importa: notifications.created_by, notifications.target_audience.user_ids, e inbox_messages.recipient_id son FKs/constraints a users.id. Un Firebase UID en esos campos rompe el insert silenciosamente. El push dispatch además consulta device_tokens en la PG de vivla-backend, indexada por vivla_user_id — el engine se encarga del mapping, los integradores solo envían users.id.

Headers

Response

Headers de respuesta:

Campos vacíos y warnings

Un integrador que manda un campo opcional como string vacío o solo espacios (guestEmail: "", guestId: " ") ya no recibe 400. El ingest normaliza esos campos a ausentes antes de validar — solo en campos que el schema declara .optional() — y devuelve el detalle en warnings[] dentro del 202/200, para que el emisor vea en su propia respuesta qué campo llegó vacío.
  • warnings está ausente (no []) cuando no se descartó ningún campo — es aditivo, nadie que ya parsea la respuesta se rompe.
  • Los campos requeridos no se tocan: un bookingId: "" sigue aceptándose tal cual (siempre lo hizo — un z.string() sin refinamiento no rechaza el string vacío), y un campo requerido con refinamiento (p. ej. user.inactive’s userEmail) sigue devolviendo 400 si llega vacío — normalizarlo endurecería el contrato en vez de rescatarlo.
  • Un email realmente inválido ("pepe@") no se rescata: sigue 400 validation_error. Solo se descarta el string vacío/blanco, nunca un valor con contenido.
  • domain_events.payload guarda la versión normalizada (sin los campos vacíos) — es la que ven las condiciones de los flows y las plantillas.

Ejemplo: booking.created

Catálogo de eventos

La tabla siguiente lista los event_type aceptados y sus campos, y es un espejo exacto del registro Zod (apps/backend/src/notifications/engine/schemas/event-schemas.ts) que valida el ingest. Campos sin ? son requeridos; con ? son opcionales pero recomendados — algunos flows los usan en templates ({{ownerName}}, {{propertyName}}). Los campos numéricos (p. ej. keysReceived, keysCount, npsScore, remainingStays) deben enviarse como número, no string. Los tipos no listados aquí se rechazan con 400 event_type_unknown (ver Eventos descartados).

Onboarding

Reservas (vivla-backend)

booking.exchange_executed es bidireccional: notifica al owner (ganó llaves) y al requester (consumió llaves). Por eso originOwnerId, requesterId y keysReceived (número de llaves) son requeridos — omitir keysReceived devuelve 400 validation_error con param: keysReceived.
booking.rent_approved: guestId es opcional. El huésped puede no tener cuenta Vivla (reserva de Airbnb, alquiler cargado a mano por un admin) — mismo modelo que booking.rent_created_by_admin. Con guestId presente, el huésped recibe push + in-app; sin guestId pero con guestEmail, recibe un email branded; sin ninguno de los dos, solo se notifica al propietario, sin error. Ver Campos vacíos y warnings — mandar guestId: "" (en vez de omitir el campo) también funciona.

Llaves (vivla-backend, incluye crons internos)

Encuestas (vivla-tools internal)

survey.completed absorbe survey.nps_high y survey.nps_low: el flow ramifica por lowestNormalized (la nota más baja normalizada a /10), no por npsScore. Cada tipo de encuesta puntúa en su propia escala (stay/home-review en 1–5, arrival-review swipe sin nota), así que el emisor normaliza cada respuesta a /10 y expone lowestNormalized como disparador. La rama baja (lowestNormalized < 8) dispara la alerta Slack a CX con el desglose pre-renderizado (breakdownText) + comentario con guard (commentDisplay). npsScore/comment se mantienen opcionales por compatibilidad. No emitas los nps_* por separado.

Tickets (vivla-tools internal)

Emitidos in-process desde el módulo de tickets (mutaciones del dashboard + webhook de Zendesk) por TicketEventsEmitterService, con el mismo patrón best-effort que survey.completed: si el emit falla nunca rompe ni ralentiza la mutación del ticket. creatorId, assigneeId y actorId son users.id (UUID) de personal interno, directamente direccionables por los canales tools_inbox/email del engine sin mapeo. actorId es quién hizo el cambio (null = cambio originado en Zendesk sin usuario interno mapeable) y permite a los flows suprimir auto-notificaciones y deduplicar creator == assignee.
ticket.updated lleva field como discriminador (status | priority | assignee | title | resolution_team | …) para ramificar el flow. ticket.assigned solo se emite cuando el nuevo asignado resuelve a un users.id; una desasignación (o un asignado sin usuario) degrada a un ticket.updated con field: assignee. La idempotencia usa ticket.created:<ticketId> y ticket.comment_added:<commentId> para colapsar el emit de Tools con el eco del webhook de Zendesk.

Oportunidades (Windmill cron)

Lifecycle de usuario (Windmill cron)

Eventos derivados (Windmill cron — reemplazan delays del builder)

Eventos descartados (no emitir)

Tras la revisión con CX (jun 2026) estos tipos no se implementan. No están en el registro Zod, así que el ingest los rechaza con 400 event_type_unknown. No los emitas:

Convenciones de naming

  • *Date (ej. startDate, endDate, expiresAt): ISO 8601 — bien YYYY-MM-DD (date) o YYYY-MM-DDTHH:mm:ssZ (datetime). Los schemas aceptan ambos.
  • *At (ej. expiresAt, previousExpiresAt): timestamp con time.
  • Transiciones: el field sin prefijo es el valor actual / efectivo; previous* / new* / old* describe la transición.

Listar tipos vía API

Health check

stuck_events cuenta domain_events.status = 'pending' con created_at < now() - 5 min — si > 0, el cron de Windmill está caído o hay un bug en el procesamiento. Hay un cron health_check cada 5 min que dispara alerta a Slack si el contador no es 0.

Errores comunes

  • event_type_unknown → revisa la grafía exacta y la versión del catálogo. Lista actualizada vía GET /public/events/registered-events.
  • validation_error con param: aggregate_id → falta el field en el payload, o tiene tipo incorrecto.
  • 429 con Retry-After: 12 → has superado 200 req/min. Encadena retries con jitter.
  • 401 authentication_invalid → confirma que estás enviando el header x-api-key (no Authorization: Bearer) y que el valor es el de la env actual (NOTIFICATIONS_API_KEY).

Referencias