Saltar al contenido principal
10 min de lectura

Seguimiento del lado del servidor

El endpoint POST /api/v1/events permite a su backend enviar eventos analíticos directamente a Zenovay. Úselo para compras capturadas desde un webhook de pago, eventos de identificación procedentes de su servicio de autenticación o cualquier otro caso en el que el navegador del cliente no sea la fuente de la verdad.

Los eventos llegan a las mismas tablas de Postgres que los eventos del rastreador del navegador, pero se etiquetan con source: 'server' para que aparezcan en los paneles junto al flujo del navegador. Los informes de conciliación en app.zenovay.com utilizan este indicador para distinguir ambos flujos.

El rastreador del navegador y el endpoint del lado del servidor son complementarios — no intercambiables. Utilice ambos. El rastreador del navegador captura las páginas vistas y la interacción; el endpoint del servidor captura los eventos del sistema de registro que no se puede confiar en que envíen los navegadores (compras, identificación, actividad fuera del sitio).

Endpoint

POST/api/v1/events

Ingesta un lote de hasta 1.000 eventos del lado del servidor

URL: https://api.zenovay.com/api/v1/events

Cabeceras:

  • Authorization: Bearer zv_... — obligatoria
  • Content-Type: application/json — obligatoria
  • Sec-GPC: 1 — opcional; cuando está presente, se rechaza todo el lote con la razón gpc_opted_out

Autenticación

Autentíquese con una clave API zv_* emitida desde app.zenovay.com → Configuración → Claves API. La clave autoriza al solicitante para un único equipo y, ya sea para un único sitio web (alcance site_access) o para todos los sitios web de ese equipo (alcance full_access).

La clave se cifra con SHA-256 en el edge y se valida contra website_api_keys. Se aplican límites mensuales y por minuto según el plan (consulte Límites de tasa).

Cuerpo de la solicitud

{
  "trackingCode": "ZV_AbC123...",
  "events": [
    {
      "type": "pageview",
      "ts": 1735689600000,
      "visitorId": "11111111-1111-4111-9111-111111111111",
      "sessionId": "22222222-2222-4222-9222-222222222222",
      "props": { "url": "https://example.com/pricing", "referrer": "" },
      "idempotencyKey": "pv-2026-05-02-abc"
    }
  ],
  "serverContext": {
    "clientIp": "203.0.113.42",
    "userAgent": "Mozilla/5.0 ...",
    "acceptLanguage": "en-US"
  }
}
CampoTipoObligatorioNotas
trackingCodestringEl código de seguimiento del sitio web, disponible en app.zenovay.com → configuración del sitio web.
eventsServerEvent[]De 1 a 1.000 eventos.
events[i].typepageview | event | identify | goal | purchaseTaxonomía de eventos — véase a continuación.
events[i].tsnumberMilisegundos Unix. Debe estar dentro de ±24 horas respecto a la hora del servidor; de lo contrario, se rechaza con invalid_ts.
events[i].sessionIdstringcondicionalObligatorio para pageview y goal. Se requiere UUID para pageview (la columna es de tipo UUID).
events[i].visitorIdstringcondicionalObligatorio para pageview, identify y goal. Se requiere UUID para pageview.
events[i].propsobjectCarga útil específica del tipo — véase a continuación.
events[i].idempotencyKeystringrecomendadoÚnico por trackingCode. Evita escrituras duplicadas durante 24 horas. Máximo 128 caracteres.
events[i].consentopt-in | opt-out | unknownopcionalopt-out rechaza únicamente ese evento con la razón consent_opted_out.
serverContext.clientIpstringopcionalSe cifra (SHA-256 con sal diaria) antes de cualquier persistencia. El texto plano nunca se almacena.

Tipos de evento

typeEscribe enprops obligatorias
pageviewvisitors, page_views, user_events, Analytics Engineurl
eventuser_events (event_type='custom')name (el nombre del evento personalizado)
identifyidentified_users (UPSERT en (website_id, visitor_id))email y/o cualquiera de: name, customer_id, phone, company, además de atributos personalizados arbitrarios
goalgoal_completions (tras buscar el objetivo por nombre)name (debe coincidir con un objetivo activo en app.zenovay.com → Objetivos); value opcional
purchasepayment_eventsamount (número), currency opcional (por defecto USD), payment_provider (stripe/lemonsqueezy/polar/server, por defecto server), status (por defecto succeeded)

Respuesta

El endpoint siempre devuelve HTTP 200 cuando la autenticación y la validación tienen éxito; los resultados por evento se informan en el cuerpo, lo que permite combinar eventos aceptados y rechazados en un mismo lote.

{
  "acceptedCount": 4,
  "dedupedCount": 1,
  "rejectedCount": 0,
  "errors": []
}

Cuando se rechazan eventos, errors enumera cada fallo con el índice original:

{
  "acceptedCount": 0,
  "dedupedCount": 0,
  "rejectedCount": 1,
  "errors": [
    { "index": 0, "reason": "invalid_ts", "message": "ts is missing or beyond ±24h skew tolerance" }
  ]
}

Idempotencia

Pase una idempotencyKey en cada evento. Durante las siguientes 24 horas, cualquier evento con el mismo (trackingCode, idempotencyKey) devuelve dedupedCount y no se escribe por segunda vez. Las claves de más de 128 caracteres o vacías se rechazan con la razón invalid_idempotency_key.

La idempotencia se aplica tanto en el edge (caché de Cloudflare KV) como en la base de datos (índices únicos parciales sobre (website_id, idempotency_key) para visitors, page_views, user_events). La capa KV es rápida; la capa de base de datos detecta el raro caso límite en el que dos eventos con la misma clave llegan a colos de Cloudflare diferentes en cuestión de milisegundos.

Límites de tasa

Los límites por minuto y por mes se heredan de su plan de suscripción:

PlanPor minutoPor mes
Free101.000
Pro3010.000
Scale60100.000
Enterprise1201.000.000

Cuando alcance un límite, el endpoint devuelve HTTP 429 con las cabeceras de respuesta estándar X-RateLimit-* y X-Usage-*. Se recomienda un backoff exponencial.

Privacidad

  • Las direcciones IP nunca se almacenan en texto plano. Si está presente serverContext.clientIp, se cifra con SHA-256 y sal diaria antes de la persistencia. Si está ausente, se almacena el valor centinela unknown.
  • GPC se respeta. Una solicitud con Sec-GPC: 1 rechaza todos los eventos del lote con la razón gpc_opted_out. No se escribe ninguna fila.
  • Consentimiento por evento. Un evento con consent: 'opt-out' se rechaza individualmente con la razón consent_opted_out; los demás eventos del mismo lote pueden seguir aceptándose.
  • El consentimiento del lado del servidor es responsabilidad del cliente. El rastreador del navegador es sin cookies y lícito antes del consentimiento conforme a ePrivacy/RGPD; la ingesta del lado del servidor se ampara, por definición, en la política de consentimiento de su propia aplicación.

Ejemplos

curl — pageviewBash
curl -X POST https://api.zenovay.com/api/v1/events \
-H "Authorization: Bearer $ZENOVAY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
  "trackingCode": "ZV_YourTrackingCode",
  "events": [{
    "type": "pageview",
    "ts": '"$(date +%s%3N)"',
    "visitorId": "11111111-1111-4111-9111-111111111111",
    "sessionId": "22222222-2222-4222-9222-222222222222",
    "props": { "url": "https://yourdomain.com/pricing" },
    "idempotencyKey": "pv-2026-05-02-pricing-1"
  }]
}'
Node.js — compra desde un webhook de StripeJavaScript
async function reportPurchaseToZenovay(stripeEvent, visitorId) {
await fetch('https://api.zenovay.com/api/v1/events', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${process.env.ZENOVAY_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    trackingCode: process.env.ZENOVAY_TRACKING_CODE,
    events: [{
      type: 'purchase',
      ts: Date.now(),
      visitorId,
      props: {
        amount: stripeEvent.data.object.amount_total / 100,
        currency: stripeEvent.data.object.currency.toUpperCase(),
        payment_provider: 'stripe',
        provider_event_id: stripeEvent.id,
        customer_email: stripeEvent.data.object.customer_email,
        plan_name: stripeEvent.data.object.metadata?.plan,
      },
      idempotencyKey: stripeEvent.id,
    }],
  }),
});
}
Python — identify tras el registroPython
import requests, time, os

def identify_user(visitor_id: str, email: str, attributes: dict):
  requests.post(
      "https://api.zenovay.com/api/v1/events",
      headers={
          "Authorization": f"Bearer {os.environ['ZENOVAY_API_KEY']}",
          "Content-Type": "application/json",
      },
      json={
          "trackingCode": os.environ["ZENOVAY_TRACKING_CODE"],
          "events": [{
              "type": "identify",
              "ts": int(time.time() * 1000),
              "visitorId": visitor_id,
              "props": {"email": email, **attributes},
              "idempotencyKey": f"id-{visitor_id}-{int(time.time())}",
          }],
      },
      timeout=5,
  ).raise_for_status()
Ruby — evento personalizado desde un job en segundo plano de RailsRuby
require 'net/http'
require 'json'
require 'securerandom'

ZENOVAY_API_KEY = ENV.fetch('ZENOVAY_API_KEY')
ZENOVAY_TRACKING_CODE = ENV.fetch('ZENOVAY_TRACKING_CODE')

def track_event_zenovay(event_name, visitor_id, properties = {})
uri = URI('https://api.zenovay.com/api/v1/events')
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
http.read_timeout = 5

request = Net::HTTP::Post.new(uri)
request['Authorization'] = "Bearer #{ZENOVAY_API_KEY}"
request['Content-Type'] = 'application/json'
request.body = {
  trackingCode: ZENOVAY_TRACKING_CODE,
  events: [{
    type: 'event',
    ts: (Time.now.to_f * 1000).to_i,
    visitorId: visitor_id,
    props: { name: event_name, **properties },
    idempotencyKey: "#{event_name}-#{visitor_id}-#{SecureRandom.hex(8)}"
  }]
}.to_json

response = http.request(request)
raise "Zenovay tracking failed: #{response.code}" unless response.code.to_i == 200
JSON.parse(response.body)
end

# Ejemplo: desde un job de Sidekiq o un controlador de Rails
track_event_zenovay('newsletter_subscribed', visitor_id, source: 'footer_form')

Razones de rechazo

reasonCuándo se produce
gpc_opted_outLa solicitud incluía Sec-GPC: 1. Se rechaza todo el lote.
consent_opted_outEl evento concreto tenía consent: 'opt-out'.
invalid_tsts falta, no es numérico o se desvía más de ±24 horas de la hora del servidor.
invalid_event_typetype no es uno de los cinco valores admitidos.
invalid_idempotency_keyidempotencyKey está vacía o supera los 128 caracteres.
invalid_visitor_idFalta el visitorId obligatorio o no tiene forma de UUID en un pageview.
invalid_session_idFalta el sessionId obligatorio o no tiene forma de UUID en un pageview.
unknown_goalprops.name no coincide con ningún objetivo personalizado activo en ese sitio web.
invalid_payment_providerprops.payment_provider no es ninguno de stripe/lemonsqueezy/polar/server.
internal_errorFallo inesperado de base de datos o de tiempo de ejecución. Reintentar con la misma idempotencyKey es seguro.

Dónde aparecen estos eventos en el panel

Cada tipo de evento aterriza en una superficie específica de Zenovay:

typeDónde aparece
pageviewPestaña Páginas, actividad en tiempo real, cronología de sesión del visitante
eventLista de eventos personalizados, cronología del visitante (filtra por event_name para encontrar un evento concreto)
identifyPágina Usuarios identificados, detalle del perfil del visitante
goalPestaña Objetivos, estadísticas de finalización de embudo
purchasePanel de Ingresos, atribución de ingresos, perfil del usuario identificado

Cada clave API también dispone de su propio panel Actividad del lado del servidor en app.zenovay.com → Ajustes → Claves API → [clic en una clave]. Muestra el recuento de eventos aceptados de 24 h y 7 d para el ámbito del sitio web de la clave, los principales tipos de eventos y los 20 eventos más recientes.

Allí solo se listan eventos aceptados. Los motivos de rechazo se devuelven de forma síncrona en el cuerpo de la respuesta de POST /api/v1/events — consúltalos al depurar tu remitente.

Lo que este endpoint NO hace

  • No crea objetivos personalizados sobre la marcha. Defínalos primero en app.zenovay.com → Objetivos.
  • No vincula la actividad fuera de línea con visitantes en línea. Esa función está en la hoja de ruta como un endpoint separado.
  • No sustituye al rastreador del navegador. Las páginas vistas enviadas desde su backend no incluirán datos de dispositivo, sistema operativo, navegador ni geolocalización, salvo que los proporcione en props.
  • No genera identificadores de visitante por usted. Utilice el UUID que ya tenga para el visitante en su lado; en los eventos pageview, ese UUID debe ser un UUID real conforme a RFC-4122.
¿Fue útil esta página?