Skip to main content
9 min read

Server-Side Tracking

The POST /api/v1/events endpoint lets your backend send analytics events directly to Zenovay. Use it for purchases captured from a payment webhook, identification events from your authentication service, or anything else where the client browser is not the source of truth.

Events arrive at the same Postgres tables as browser-tracker events but are tagged with source: 'server' so they show up in dashboards alongside the browser stream. Reconciliation reports in app.zenovay.com use this flag to distinguish the two streams.

The browser tracker and the server-side endpoint are complementary — not interchangeable. Use both. The browser tracker captures pageviews and engagement; the server endpoint captures system-of-record events that browsers cannot be trusted to send (purchases, identification, off-site activity).

Endpoint

POST/api/v1/events

Ingest a batch of up to 1,000 server-side events

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

Headers:

  • Authorization: Bearer zv_... — required
  • Content-Type: application/json — required
  • Sec-GPC: 1 — optional; when present, the entire batch is rejected with reason gpc_opted_out

Authentication

Authenticate with a zv_* API key issued from app.zenovay.com → Settings → API Keys. The key authorises the caller to a single team, and either a single website (scope site_access) or every website on that team (scope full_access).

The key is hashed with SHA-256 at the edge and validated against website_api_keys. Per-tier monthly and per-minute limits apply (see Rate Limits).

Request body

{
  "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"
  }
}
FieldTypeRequiredNotes
trackingCodestringyesThe website's tracking code, found in app.zenovay.com → website settings.
eventsServerEvent[]yes1–1,000 events.
events[i].typepageview | event | identify | goal | purchaseyesEvent taxonomy — see below.
events[i].tsnumberyesUnix milliseconds. Must be within ±24 hours of server time, else rejected with invalid_ts.
events[i].sessionIdstringconditionalRequired for pageview and goal. UUID required for pageview (the column is UUID-typed).
events[i].visitorIdstringconditionalRequired for pageview, identify, and goal. UUID required for pageview.
events[i].propsobjectyesType-specific payload — see below.
events[i].idempotencyKeystringrecommendedUnique per trackingCode. Prevents duplicate writes for 24 hours. Max 128 characters.
events[i].consentopt-in | opt-out | unknownoptionalopt-out rejects only that one event with reason consent_opted_out.
serverContext.clientIpstringoptionalHashed (daily-salted SHA-256) before any persistence. Plaintext is never stored.

Event types

typeWrites toRequired props
pageviewvisitors, page_views, user_events, Analytics Engineurl
eventuser_events (event_type='custom')name (the custom event name)
identifyidentified_users (UPSERT on (website_id, visitor_id))email and/or any of: name, customer_id, phone, company, plus arbitrary custom attributes
goalgoal_completions (after looking up the goal by name)name (must match an active goal in app.zenovay.com → Goals); optional value
purchasepayment_eventsamount (number), optional currency (default USD), payment_provider (stripe/lemonsqueezy/polar/server, default server), status (default succeeded)

Response

The endpoint always returns HTTP 200 when authentication and validation succeed; per-event outcomes are reported in the body so you can mix successful and rejected events in one batch.

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

When events are rejected, errors lists each failure with the original index:

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

Idempotency

Pass an idempotencyKey on every event. Within the next 24 hours, any event with the same (trackingCode, idempotencyKey) returns dedupedCount and is not written a second time. Keys longer than 128 characters or empty are rejected with reason invalid_idempotency_key.

Idempotency is enforced both at the edge (Cloudflare KV cache) and at the database level (partial unique indexes on (website_id, idempotency_key) for visitors, page_views, user_events). The KV layer is fast; the database layer catches the rare edge case where two events for the same key arrive in different Cloudflare colos within milliseconds.

Rate limits

Per-minute and per-month limits inherit from your subscription tier:

TierPer-minutePer-month
Free101,000
Pro3010,000
Scale60100,000
Enterprise1201,000,000

When you hit a limit, the endpoint returns HTTP 429 with the standard X-RateLimit-* and X-Usage-* response headers. Exponential backoff is recommended.

Privacy

  • IPs are never stored in plaintext. If serverContext.clientIp is present, it is hashed with daily-salted SHA-256 before persistence. If absent, the value unknown is stored as a sentinel.
  • GPC is honoured. A request with Sec-GPC: 1 rejects every event in the batch with reason gpc_opted_out. No row is written.
  • Per-event consent. An event with consent: 'opt-out' is rejected individually with reason consent_opted_out; other events in the same batch may still be accepted.
  • Server-side consent is the customer's responsibility. The browser tracker is cookieless and lawful pre-consent under ePrivacy/GDPR; server-side ingestion is by definition under your application's own consent posture.

Examples

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 — purchase from a Stripe webhookJavaScript
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 after sign-upPython
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 — custom event from a Rails background jobRuby
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

# Example: from a Sidekiq job or Rails controller
track_event_zenovay('newsletter_subscribed', visitor_id, source: 'footer_form')

Rejection reasons

reasonWhen it happens
gpc_opted_outThe request had Sec-GPC: 1. The whole batch is rejected.
consent_opted_outThe single event had consent: 'opt-out'.
invalid_tsts is missing, non-numeric, or beyond ±24 hours of server time.
invalid_event_typetype is not one of the five supported values.
invalid_idempotency_keyidempotencyKey is empty or longer than 128 characters.
invalid_visitor_idRequired visitorId missing, or not UUID-shaped for a pageview.
invalid_session_idRequired sessionId missing, or not UUID-shaped for a pageview.
unknown_goalprops.name doesn't match any active custom goal on that website.
invalid_payment_providerprops.payment_provider is not one of stripe/lemonsqueezy/polar/server.
internal_errorUnexpected database or runtime failure. Retry with the same idempotencyKey is safe.

Where these events appear in the dashboard

Each event type lands on a specific Zenovay surface:

typeWhere it shows up
pageviewPages tab, real-time activity, visitor session timeline
eventCustom events list, visitor timeline (filter by event_name to find a specific event)
identifyIdentified Users page, visitor profile detail
goalGoals tab, funnel-completion stats
purchaseRevenue dashboard, revenue attribution, identified-user profile

Each API key also has its own Server-side activity panel at app.zenovay.com → Settings → API Keys → [click a key]. It shows accepted-event counts for the last 24 hours and 7 days within the key's website scope, the top event types, and the most recent 20 events.

Only accepted events are listed there. Rejection reasons are returned synchronously in the response body of POST /api/v1/events — check those when debugging your sender.

What this endpoint does NOT do

  • It does not create custom goals on the fly. Define them first in app.zenovay.com → Goals.
  • It does not match offline activity to online visitors. That feature is on the roadmap as a separate endpoint.
  • It does not replace the browser tracker. Pageviews from your backend will not have device, OS, browser, or geo data unless you provide them in props.
  • It does not generate visitor IDs for you. Use the UUID you already have for the visitor on your side; for pageview events, that UUID must be a real RFC-4122 UUID.
Was this page helpful?