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
/api/v1/eventsIngesta un lote de hasta 1.000 eventos del lado del servidor
URL: https://api.zenovay.com/api/v1/events
Cabeceras:
Authorization: Bearer zv_...— obligatoriaContent-Type: application/json— obligatoriaSec-GPC: 1— opcional; cuando está presente, se rechaza todo el lote con la razóngpc_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"
}
}
| Campo | Tipo | Obligatorio | Notas |
|---|---|---|---|
trackingCode | string | sí | El código de seguimiento del sitio web, disponible en app.zenovay.com → configuración del sitio web. |
events | ServerEvent[] | sí | De 1 a 1.000 eventos. |
events[i].type | pageview | event | identify | goal | purchase | sí | Taxonomía de eventos — véase a continuación. |
events[i].ts | number | sí | Milisegundos Unix. Debe estar dentro de ±24 horas respecto a la hora del servidor; de lo contrario, se rechaza con invalid_ts. |
events[i].sessionId | string | condicional | Obligatorio para pageview y goal. Se requiere UUID para pageview (la columna es de tipo UUID). |
events[i].visitorId | string | condicional | Obligatorio para pageview, identify y goal. Se requiere UUID para pageview. |
events[i].props | object | sí | Carga útil específica del tipo — véase a continuación. |
events[i].idempotencyKey | string | recomendado | Único por trackingCode. Evita escrituras duplicadas durante 24 horas. Máximo 128 caracteres. |
events[i].consent | opt-in | opt-out | unknown | opcional | opt-out rechaza únicamente ese evento con la razón consent_opted_out. |
serverContext.clientIp | string | opcional | Se cifra (SHA-256 con sal diaria) antes de cualquier persistencia. El texto plano nunca se almacena. |
Tipos de evento
type | Escribe en | props obligatorias |
|---|---|---|
pageview | visitors, page_views, user_events, Analytics Engine | url |
event | user_events (event_type='custom') | name (el nombre del evento personalizado) |
identify | identified_users (UPSERT en (website_id, visitor_id)) | email y/o cualquiera de: name, customer_id, phone, company, además de atributos personalizados arbitrarios |
goal | goal_completions (tras buscar el objetivo por nombre) | name (debe coincidir con un objetivo activo en app.zenovay.com → Objetivos); value opcional |
purchase | payment_events | amount (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:
| Plan | Por minuto | Por mes |
|---|---|---|
| Free | 10 | 1.000 |
| Pro | 30 | 10.000 |
| Scale | 60 | 100.000 |
| Enterprise | 120 | 1.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 centinelaunknown. - GPC se respeta. Una solicitud con
Sec-GPC: 1rechaza todos los eventos del lote con la razóngpc_opted_out. No se escribe ninguna fila. - Consentimiento por evento. Un evento con
consent: 'opt-out'se rechaza individualmente con la razónconsent_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 -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"
}]
}'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,
}],
}),
});
}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()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
reason | Cuándo se produce |
|---|---|
gpc_opted_out | La solicitud incluía Sec-GPC: 1. Se rechaza todo el lote. |
consent_opted_out | El evento concreto tenía consent: 'opt-out'. |
invalid_ts | ts falta, no es numérico o se desvía más de ±24 horas de la hora del servidor. |
invalid_event_type | type no es uno de los cinco valores admitidos. |
invalid_idempotency_key | idempotencyKey está vacía o supera los 128 caracteres. |
invalid_visitor_id | Falta el visitorId obligatorio o no tiene forma de UUID en un pageview. |
invalid_session_id | Falta el sessionId obligatorio o no tiene forma de UUID en un pageview. |
unknown_goal | props.name no coincide con ningún objetivo personalizado activo en ese sitio web. |
invalid_payment_provider | props.payment_provider no es ninguno de stripe/lemonsqueezy/polar/server. |
internal_error | Fallo 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:
type | Dónde aparece |
|---|---|
pageview | Pestaña Páginas, actividad en tiempo real, cronología de sesión del visitante |
event | Lista de eventos personalizados, cronología del visitante (filtra por event_name para encontrar un evento concreto) |
identify | Página Usuarios identificados, detalle del perfil del visitante |
goal | Pestaña Objetivos, estadísticas de finalización de embudo |
purchase | Panel 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.