Rastreamento no lado do servidor
O endpoint POST /api/v1/events permite que o seu backend envie eventos analíticos diretamente para o Zenovay. Use-o para compras capturadas a partir de um webhook de pagamento, eventos de identificação vindos do seu serviço de autenticação ou qualquer outra situação em que o navegador do cliente não seja a fonte da verdade.
Os eventos chegam às mesmas tabelas Postgres que os eventos do rastreador de navegador, mas são marcados com source: 'server' para que apareçam nos painéis lado a lado com o fluxo do navegador. Os relatórios de reconciliação em app.zenovay.com utilizam essa flag para diferenciar os dois fluxos.
O rastreador de navegador e o endpoint do lado do servidor são complementares — não intercambiáveis. Use ambos. O rastreador de navegador captura page views e engajamento; o endpoint de servidor captura eventos do sistema de registro que não se pode confiar que os navegadores enviem (compras, identificação, atividade fora do site).
Endpoint
/api/v1/eventsFaz a ingestão de um lote de até 1.000 eventos do lado do servidor
URL: https://api.zenovay.com/api/v1/events
Cabeçalhos:
Authorization: Bearer zv_...— obrigatórioContent-Type: application/json— obrigatórioSec-GPC: 1— opcional; quando presente, o lote inteiro é rejeitado com o motivogpc_opted_out
Autenticação
Autentique-se com uma chave de API zv_* emitida em app.zenovay.com → Configurações → Chaves de API. A chave autoriza o chamador para uma única equipe e, ou para um único site (escopo site_access), ou para todos os sites dessa equipe (escopo full_access).
A chave é hasheada com SHA-256 na borda e validada contra website_api_keys. Aplicam-se limites mensais e por minuto específicos do plano (consulte Limites de taxa).
Corpo da requisição
{
"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 | Obrigatório | Notas |
|---|---|---|---|
trackingCode | string | sim | O código de rastreamento do site, encontrado em app.zenovay.com → configurações do site. |
events | ServerEvent[] | sim | De 1 a 1.000 eventos. |
events[i].type | pageview | event | identify | goal | purchase | sim | Taxonomia de eventos — veja a seguir. |
events[i].ts | number | sim | Milissegundos Unix. Deve estar dentro de ±24 horas em relação ao horário do servidor; caso contrário, é rejeitado com invalid_ts. |
events[i].sessionId | string | condicional | Obrigatório para pageview e goal. UUID obrigatório para pageview (a coluna é do tipo UUID). |
events[i].visitorId | string | condicional | Obrigatório para pageview, identify e goal. UUID obrigatório para pageview. |
events[i].props | object | sim | Carga útil específica do tipo — veja a seguir. |
events[i].idempotencyKey | string | recomendado | Único por trackingCode. Evita escritas duplicadas por 24 horas. Máximo de 128 caracteres. |
events[i].consent | opt-in | opt-out | unknown | opcional | opt-out rejeita apenas esse evento com o motivo consent_opted_out. |
serverContext.clientIp | string | opcional | Hasheado (SHA-256 com sal diário) antes de qualquer persistência. O texto em claro nunca é armazenado. |
Tipos de evento
type | Grava em | props obrigatórios |
|---|---|---|
pageview | visitors, page_views, user_events, Analytics Engine | url |
event | user_events (event_type='custom') | name (o nome do evento personalizado) |
identify | identified_users (UPSERT em (website_id, visitor_id)) | email e/ou qualquer um de: name, customer_id, phone, company, mais atributos personalizados arbitrários |
goal | goal_completions (após buscar o objetivo pelo nome) | name (deve corresponder a um objetivo ativo em app.zenovay.com → Objetivos); value opcional |
purchase | payment_events | amount (número), currency opcional (padrão USD), payment_provider (stripe/lemonsqueezy/polar/server, padrão server), status (padrão succeeded) |
Resposta
O endpoint sempre retorna HTTP 200 quando a autenticação e a validação são bem-sucedidas; os resultados por evento são reportados no corpo, permitindo misturar eventos aceitos e rejeitados em um mesmo lote.
{
"acceptedCount": 4,
"dedupedCount": 1,
"rejectedCount": 0,
"errors": []
}
Quando há eventos rejeitados, errors lista cada falha junto com o índice original:
{
"acceptedCount": 0,
"dedupedCount": 0,
"rejectedCount": 1,
"errors": [
{ "index": 0, "reason": "invalid_ts", "message": "ts is missing or beyond ±24h skew tolerance" }
]
}
Idempotência
Passe um idempotencyKey em todo evento. Nas próximas 24 horas, qualquer evento com o mesmo (trackingCode, idempotencyKey) retorna dedupedCount e não é gravado uma segunda vez. Chaves com mais de 128 caracteres ou vazias são rejeitadas com o motivo invalid_idempotency_key.
A idempotência é aplicada tanto na borda (cache do Cloudflare KV) quanto no banco de dados (índices únicos parciais em (website_id, idempotency_key) para visitors, page_views, user_events). A camada KV é rápida; a camada de banco de dados captura o raro caso extremo em que dois eventos com a mesma chave chegam a colos diferentes da Cloudflare em milissegundos.
Limites de taxa
Os limites por minuto e por mês herdam do seu plano de assinatura:
| Plano | Por minuto | Por mês |
|---|---|---|
| Free | 10 | 1.000 |
| Pro | 30 | 10.000 |
| Scale | 60 | 100.000 |
| Enterprise | 120 | 1.000.000 |
Quando você atinge um limite, o endpoint retorna HTTP 429 com os cabeçalhos de resposta padrão X-RateLimit-* e X-Usage-*. Recomenda-se backoff exponencial.
Privacidade
- IPs nunca são armazenados em texto puro. Se
serverContext.clientIpestiver presente, ele é hasheado com SHA-256 e sal diário antes da persistência. Se ausente, o valor sentinelaunknowné armazenado. - GPC é respeitado. Uma requisição com
Sec-GPC: 1rejeita todos os eventos do lote com o motivogpc_opted_out. Nenhuma linha é gravada. - Consentimento por evento. Um evento com
consent: 'opt-out'é rejeitado individualmente com o motivoconsent_opted_out; outros eventos do mesmo lote ainda podem ser aceitos. - O consentimento no lado do servidor é responsabilidade do cliente. O rastreador de navegador é sem cookies e lícito antes do consentimento sob ePrivacy/LGPD/RGPD; a ingestão no lado do servidor está, por definição, sob a política de consentimento da sua própria aplicação.
Exemplos
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
# Exemplo: a partir de um job do Sidekiq ou um controller do Rails
track_event_zenovay('newsletter_subscribed', visitor_id, source: 'footer_form')Motivos de rejeição
reason | Quando ocorre |
|---|---|
gpc_opted_out | A requisição continha Sec-GPC: 1. Todo o lote é rejeitado. |
consent_opted_out | O evento individual tinha consent: 'opt-out'. |
invalid_ts | ts está ausente, não é numérico ou ultrapassa ±24 horas em relação ao horário do servidor. |
invalid_event_type | type não corresponde a nenhum dos cinco valores suportados. |
invalid_idempotency_key | idempotencyKey está vazio ou tem mais de 128 caracteres. |
invalid_visitor_id | visitorId obrigatório ausente, ou fora do formato UUID em um pageview. |
invalid_session_id | sessionId obrigatório ausente, ou fora do formato UUID em um pageview. |
unknown_goal | props.name não corresponde a nenhum objetivo personalizado ativo nesse site. |
invalid_payment_provider | props.payment_provider não é nenhum de stripe/lemonsqueezy/polar/server. |
internal_error | Falha inesperada de banco de dados ou tempo de execução. Tentar novamente com o mesmo idempotencyKey é seguro. |
Onde estes eventos aparecem no painel
Cada tipo de evento aterrissa em uma superfície específica do Zenovay:
type | Onde aparece |
|---|---|
pageview | Aba Páginas, atividade em tempo real, cronologia de sessão do visitante |
event | Lista de eventos personalizados, cronologia do visitante (filtre por event_name para encontrar um evento específico) |
identify | Página Usuários identificados, detalhe do perfil do visitante |
goal | Aba Objetivos, estatísticas de conclusão de funil |
purchase | Painel de Receita, atribuição de receita, perfil do usuário identificado |
Cada chave de API também tem o próprio painel Atividade do lado do servidor em app.zenovay.com → Configurações → Chaves de API → [clique em uma chave]. Mostra a contagem de eventos aceitos de 24 h e 7 d para o escopo de site da chave, os principais tipos de evento e os 20 eventos mais recentes.
Ali são listados apenas eventos aceitos. Os motivos de rejeição são retornados de forma síncrona no corpo da resposta de POST /api/v1/events — consulte-os ao depurar o seu remetente.
O que este endpoint NÃO faz
- Não cria objetivos personalizados sob demanda. Defina-os primeiro em app.zenovay.com → Objetivos.
- Não correlaciona atividade off-line com visitantes on-line. Esse recurso está no roadmap como um endpoint separado.
- Não substitui o rastreador de navegador. Page views vindos do seu backend não terão dados de dispositivo, SO, navegador ou geolocalização a menos que você os forneça em
props. - Não gera IDs de visitante para você. Use o UUID que você já tem para o visitante do seu lado; em eventos
pageview, esse UUID precisa ser um UUID real, conforme a RFC-4122.