Pular para o conteúdo principal
10 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 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 usam 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 pageviews 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 Settings → Security → API keys. 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 Domains → [seu site] → General (cartão de script de rastreamento).
eventsServerEvent[]simDe 1 a 1.000 eventos.
events[i].typepageview | event | identify | goal | purchasesimTaxonomia de eventos — veja abaixo.
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 abaixo.
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 configurado para esse site); 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

A API do lado do servidor requer um plano pago. Contas gratuitas recebem 403 API_PAID_PLAN_REQUIRED ao tentar usar chaves de API.

Limites por minuto e por mês por plano:

PlanoPor minutoPor mês
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 com 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/GDPR; 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 — identificar 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 não está no formato UUID para um pageview.
invalid_session_idsessionId obrigatório ausente, ou não está no formato UUID para 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 Pages, 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 Goals, 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 seu próprio painel Atividade do lado do servidor, acessível em Settings → Security → API keys clicando em uma chave. Mostra a contagem de eventos aceitos dos últimos 24 horas e 7 dias dentro do escopo de site da chave, os principais tipos de evento e os 20 eventos mais recentes.

Apenas eventos aceitos são listados ali. 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

  • Ele não cria objetivos personalizados sob demanda. Defina-os primeiro nas configurações de domínio do seu site antes de enviar eventos goal.
  • Ele não correlaciona atividade off-line com visitantes on-line. Esse recurso está no roadmap como um endpoint separado.
  • Ele não substitui o rastreador de navegador. Pageviews 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.
  • Ele 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?