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

POST /api/public/events/ingest
Content-Type: application/json
x-api-key: <NOTIFICATIONS_API_KEY>
Idempotency-Key: <string arbitrario, opcional, recomendado>

{
  "event_type": "booking.created",
  "aggregate_id": "booking_abc123",
  "aggregate_type": "booking",
  "source": "vivla-backend",
  "payload": { ... }
}
CampoTipoRequeridoNotas
event_typestringDebe estar en el catálogo (§ Catálogo abajo).
aggregate_idstringID de la entidad principal (ej. bookingId, userId).
aggregate_typestringCategoría (booking, user, survey, keys, …).
payloadobjectValidado contra Zod schema específico de event_type.
sourcestringDefault external. Identificar el emisor (vivla-backend, windmill, tools-internal).
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:
SELECT id, vivla_user_id FROM users WHERE id = <payload.userId>
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

HeaderTipoRequeridoNotas
x-api-keystringValor de NOTIFICATIONS_API_KEY.
Content-Typeapplication/json
Idempotency-Keystring ≤255Token de idempotencia. Recomendado para retries seguros. Detalle en idempotency.
X-Request-IDstring ≤200Si lo provees, se propaga a logs y respuesta. Si no, el server genera uno.

Response

StatusSignificadoBody
202 AcceptedEvento aceptado y persistido. Se procesa en próximos ~30s por Windmill.{ object, id, type, status: 'pending', created_at }
200 OKIdempotent — la misma Idempotency-Key ya fue aceptada.{ object, id, type, status: 'idempotent', created_at }
400 Bad RequestValidación falló o event_type desconocido.Error envelope. Incluye details[] con cada problema.
401 UnauthorizedFalta o inválido x-api-key.Error envelope.
409 ConflictIdempotency-Key reutilizada con un body distinto.Error envelope.
429 Too Many RequestsRate limit. Header Retry-After con segundos.Error envelope.
500/503Bug o dependencia caída. Reintentar con backoff.Error envelope.
Headers de respuesta:
HeaderSiempreNotas
X-Request-IDIdentificador único de la request.
X-RateLimit-Limit200
X-RateLimit-RemainingRequests restantes en la ventana de 60s.
X-RateLimit-ResetSegundos hasta reset.
Retry-Afteren 429Segundos a esperar.

Ejemplo: booking.created

curl -i -X POST 'https://tools.vivla.com/api/public/events/ingest' \
  -H 'x-api-key: <NOTIFICATIONS_API_KEY>' \
  -H 'Idempotency-Key: booking_abc123_created' \
  -H 'Content-Type: application/json' \
  -d '{
    "event_type": "booking.created",
    "aggregate_id": "booking_abc123",
    "aggregate_type": "booking",
    "source": "vivla-backend",
    "payload": {
      "bookingId": "booking_abc123",
      "ownerId": "user_xyz",
      "ownerName": "Juan Pérez",
      "ownerEmail": "juan@example.com",
      "propertyId": "prop_001",
      "propertyName": "Casa del Mar",
      "propertyLocation": "Ibiza",
      "startDate": "2026-06-15",
      "endDate": "2026-06-22"
    }
  }'
const res = await fetch('https://tools.vivla.com/api/public/events/ingest', {
  method: 'POST',
  headers: {
    'x-api-key': process.env.NOTIFICATIONS_API_KEY!,
    'Idempotency-Key': `booking_${booking.id}_created`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    event_type: 'booking.created',
    aggregate_id: booking.id,
    aggregate_type: 'booking',
    source: 'vivla-backend',
    payload: {
      bookingId: booking.id,
      ownerId: booking.ownerId,
      ownerName: booking.ownerName,
      ownerEmail: booking.ownerEmail,
      propertyId: booking.propertyId,
      propertyName: booking.propertyName,
      propertyLocation: booking.propertyLocation,
      startDate: booking.startDate,
      endDate: booking.endDate,
    },
  }),
})
const event = await res.json()
HTTP/1.1 202 Accepted
Content-Type: application/json
X-Request-ID: req_a1b2c3d4e5f6
X-RateLimit-Limit: 200
X-RateLimit-Remaining: 199
X-RateLimit-Reset: 60

{
  "object": "event",
  "id": "ev_550e8400-e29b-41d4-a716-446655440000",
  "type": "booking.created",
  "status": "pending",
  "created_at": "2026-05-05T10:00:00.000Z"
}
HTTP/1.1 200 OK
Content-Type: application/json
X-Request-ID: req_b2c3d4e5f6g7

{
  "object": "event",
  "id": "ev_550e8400-e29b-41d4-a716-446655440000",
  "type": "booking.created",
  "status": "idempotent",
  "created_at": "2026-05-05T10:00:00.000Z"
}
HTTP/1.1 400 Bad Request
Content-Type: application/json
X-Request-ID: req_c3d4e5f6g7h8

{
  "error": {
    "type": "invalid_request_error",
    "code": "validation_error",
    "message": "Payload does not match schema for booking.created",
    "param": "bookingId",
    "request_id": "req_c3d4e5f6g7h8",
    "details": [
      { "path": "bookingId", "message": "Required" }
    ]
  }
}

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

event_typePayload
user.welcomeduserId, userEmail?, userName?, userLang?
user.first_loginuserId, userEmail?, userName?, userLang?

Reservas (vivla-backend)

event_typePayload
booking.createdbookingId, ownerId, ownerEmail?, ownerName?, ownerLang?, propertyId?, propertyName?, propertyLocation?, startDate?, endDate?, duration?, seasonSlug?, isLastHour?
booking.info_updatedbookingId, ownerId, adults?, kids?, pets?, cleaningRequested?, cribRequested?
booking.cancelledbookingId, bookingType?, cancelledByUserId?, cancelledByName?, cancelledRole?, ownerId?, guestId?, propertyId?, propertyName?, startDate?, endDate?, reason?
booking.guest_invitedbookingId, ownerId, guestId, guestName?, guestEmail?, propertyName?, startDate?, endDate?
booking.rent_requestedbookingId, originBookingId?, ownerId, ownerEmail?, ownerName?, ownerLang?, guestId, guestName?, guestEmail?, propertyId?, propertyName?, propertyLocation?, startDate?, endDate?
booking.rent_approvedbookingId, originBookingId?, ownerId, ownerEmail?, ownerName?, ownerLang?, guestId, guestName?, guestEmail?, propertyId?, propertyName?, propertyLocation?, startDate?, endDate?, finalPrice?, currency?
booking.rent_created_by_adminbookingId, originBookingId?, ownerId, ownerEmail?, ownerName?, ownerLang?, guestId?, guestName?, guestEmail?, isExternalGuest?, propertyId?, propertyName?, propertyLocation?, startDate?, endDate?, finalPrice?
booking.exchange_executedbookingId, originBookingId?, originOwnerId, originOwnerName?, originPropertyName?, requesterId, requesterName?, keysReceived, startDate?, endDate?
booking.liberatedbookingId, ownerId, removedGuestIds?, propertyName?
booking.third_home_publishedbookingId, ownerId, propertyName?, startDate?, endDate?
booking.third_home_exchangedbookingId, ownerId, propertyName?, externalGuestName?, externalGuestEmail?, startDate?, endDate?
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.

Llaves (vivla-backend, incluye crons internos)

event_typePayload
keys.recovereduserId, userName?, keysReturned, propertyName?, bookingId?, originBookingId?
keys.expiring_soonuserId, userName?, keysCount, daysUntilExpiry, expiresAt
keys.extendeduserId, userName?, keysCount, expiresAt, previousExpiresAt?
keys.fidelity_bonus_crediteduserId, userName?, keysGranted, yearExchangesCount?, year, expiresAt?

Encuestas (vivla-tools internal)

event_typePayload
survey.completedresponseId, surveyId, surveyType, userId, userName?, userEmail?, userPhone?, propertyId?, propertyName?, bookingId?, npsScore?, comment?, lowestNormalized?, surveyLabel?, breakdownText?, commentDisplay?, scores?
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.

Oportunidades (Windmill cron)

event_typePayload
booking.stays_available_reminderuserId, userName?, propertyId, propertyName, remainingStays

Lifecycle de usuario (Windmill cron)

event_typePayload
user.inactiveuserId, userEmail, userName?, userLang?, keysCount, propertyName, daysSinceLastKeyUse

Eventos derivados (Windmill cron — reemplazan delays del builder)

event_typePayloadCuándo se emite
booking.pre_stay_30d_duebookingId, ownerId, ownerEmail?, ownerName?, ownerLang?, propertyId?, propertyName?, propertyLocation?, startDate?, endDate?Daily 09:00 — bookings con startDate = today + 30d
booking.pre_stay_7d_duebookingId, ownerId, ownerEmail?, ownerName?, ownerLang?, propertyId?, propertyName?, propertyLocation?, startDate?, endDate?Stand-by — reactivar cuando esté el chat owner-CX en la app
booking.pre_stay_3d_duebookingId, ownerId, ownerEmail?, ownerName?, ownerLang?, propertyId?, propertyName?, propertyLocation?, startDate?, endDate?Daily 09:00 — bookings con startDate = today + 3d
booking.arrival_review_duebookingId, ownerId, ownerName?, propertyName?Daily 10:00 — bookings con startDate = today - 1d sin respuesta a arrival_review
booking.stay_review_duebookingId, ownerId, ownerName?, propertyName?Daily 10:00 (morning-after) — bookings con endDate = today - 1d sin respuesta a stay_review
survey.reminder_3d_duesurveyId, surveyType, userId, userName?, propertyId?, propertyName?, bookingId?, surveyUrl?Daily 09:00 — surveys lanzados hace 3 días sin completar. F032 ramifica por surveyType a deep links nativos (bookingId en stay/arrival-review, propertyId=hid en arrival-review)

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:
event_typeMotivo
booking.listed_for_exchangeEl intercambio va por saldo de llaves de forma automática; los eventos keys.* ya cubren los momentos que importan al owner.
booking.exchange_requestedIgual: con intercambio automático por llaves no hay “solicitud” formal que notificar.
booking.exchange_matchedFeedback negativo sobre matches sugeridos; no proactivar hasta repensar el algoritmo.
booking.dates_changedSin utilidad operativa; los cambios de fecha los comunica el operador directamente.
booking.rent_rejectedMuchos envían solicitudes solo para descubrir precios; notificar cada rechazo sería ruido constante.
booking.rent_opportunityNo se emite; sin flow asociado en este lanzamiento.
user.payment_not_setupSin caso de uso claro para este lanzamiento; vendrá cuando producto defina el flujo de onboarding incompleto.
survey.nps_highFusionado en survey.completed (branching por lowestNormalized).
survey.nps_lowFusionado en survey.completed (branching por lowestNormalized + alerta Slack a CX).

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

GET /api/public/events/registered-events

 {
  "object": "list",
  "data": ["user.welcomed", "booking.created", "booking.cancelled", ...]
}

Health check

GET /api/public/events/health

 {
  "object": "health",
  "stuck_events": 0,
  "failed_events": 0
}
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