Pular para o conteúdo principal
9 min de leitura

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

POST/api/v1/events

Faz 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ório
  • Content-Type: application/json — obrigatório
  • Sec-GPC: 1 — opcional; quando presente, o lote inteiro é rejeitado com o motivo gpc_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"
  }
}
CampoTipoObrigatórioNotas
trackingCodestringsimO código de rastreamento do site, encontrado em app.zenovay.com → configurações do site.
eventsServerEvent[]simDe 1 a 1.000 eventos.
events[i].typepageview | event | identify | goal | purchasesimTaxonomia de eventos — veja a seguir.
events[i].tsnumbersimMilissegundos Unix. Deve estar dentro de ±24 horas em relação ao horário do servidor; caso contrário, é rejeitado com invalid_ts.
events[i].sessionIdstringcondicionalObrigatório para pageview e goal. UUID obrigatório para pageview (a coluna é do tipo UUID).
events[i].visitorIdstringcondicionalObrigatório para pageview, identify e goal. UUID obrigatório para pageview.
events[i].propsobjectsimCarga útil específica do tipo — veja a seguir.
events[i].idempotencyKeystringrecomendadoÚnico por trackingCode. Evita escritas duplicadas por 24 horas. Máximo de 128 caracteres.
events[i].consentopt-in | opt-out | unknownopcionalopt-out rejeita apenas esse evento com o motivo consent_opted_out.
serverContext.clientIpstringopcionalHasheado (SHA-256 com sal diário) antes de qualquer persistência. O texto em claro nunca é armazenado.

Tipos de evento

typeGrava emprops obrigatórios
pageviewvisitors, page_views, user_events, Analytics Engineurl
eventuser_events (event_type='custom')name (o nome do evento personalizado)
identifyidentified_users (UPSERT em (website_id, visitor_id))email e/ou qualquer um de: name, customer_id, phone, company, mais atributos personalizados arbitrários
goalgoal_completions (após buscar o objetivo pelo nome)name (deve corresponder a um objetivo ativo em app.zenovay.com → Objetivos); value opcional
purchasepayment_eventsamount (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:

PlanoPor minutoPor mês
Free101.000
Pro3010.000
Scale60100.000
Enterprise1201.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.clientIp estiver presente, ele é hasheado com SHA-256 e sal diário antes da persistência. Se ausente, o valor sentinela unknown é armazenado.
  • GPC é respeitado. Uma requisição com Sec-GPC: 1 rejeita todos os eventos do lote com o motivo gpc_opted_out. Nenhuma linha é gravada.
  • Consentimento por evento. Um evento com consent: 'opt-out' é rejeitado individualmente com o motivo consent_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 — 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 a partir de um webhook do 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 após o cadastroPython
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 a partir de um job em segundo plano do 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

# 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

reasonQuando ocorre
gpc_opted_outA requisição continha Sec-GPC: 1. Todo o lote é rejeitado.
consent_opted_outO evento individual tinha consent: 'opt-out'.
invalid_tsts está ausente, não é numérico ou ultrapassa ±24 horas em relação ao horário do servidor.
invalid_event_typetype não corresponde a nenhum dos cinco valores suportados.
invalid_idempotency_keyidempotencyKey está vazio ou tem mais de 128 caracteres.
invalid_visitor_idvisitorId obrigatório ausente, ou fora do formato UUID em um pageview.
invalid_session_idsessionId obrigatório ausente, ou fora do formato UUID em um pageview.
unknown_goalprops.name não corresponde a nenhum objetivo personalizado ativo nesse site.
invalid_payment_providerprops.payment_provider não é nenhum de stripe/lemonsqueezy/polar/server.
internal_errorFalha 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:

typeOnde aparece
pageviewAba Páginas, atividade em tempo real, cronologia de sessão do visitante
eventLista de eventos personalizados, cronologia do visitante (filtre por event_name para encontrar um evento específico)
identifyPágina Usuários identificados, detalhe do perfil do visitante
goalAba Objetivos, estatísticas de conclusão de funil
purchasePainel 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.
Esta página foi útil?