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
/api/v1/eventsIngest a batch of up to 1,000 server-side events
URL: https://api.zenovay.com/api/v1/events
Headers:
Authorization: Bearer zv_...— requiredContent-Type: application/json— requiredSec-GPC: 1— optional; when present, the entire batch is rejected with reasongpc_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"
}
}
| Field | Type | Required | Notes |
|---|---|---|---|
trackingCode | string | yes | The website's tracking code, found in app.zenovay.com → website settings. |
events | ServerEvent[] | yes | 1–1,000 events. |
events[i].type | pageview | event | identify | goal | purchase | yes | Event taxonomy — see below. |
events[i].ts | number | yes | Unix milliseconds. Must be within ±24 hours of server time, else rejected with invalid_ts. |
events[i].sessionId | string | conditional | Required for pageview and goal. UUID required for pageview (the column is UUID-typed). |
events[i].visitorId | string | conditional | Required for pageview, identify, and goal. UUID required for pageview. |
events[i].props | object | yes | Type-specific payload — see below. |
events[i].idempotencyKey | string | recommended | Unique per trackingCode. Prevents duplicate writes for 24 hours. Max 128 characters. |
events[i].consent | opt-in | opt-out | unknown | optional | opt-out rejects only that one event with reason consent_opted_out. |
serverContext.clientIp | string | optional | Hashed (daily-salted SHA-256) before any persistence. Plaintext is never stored. |
Event types
type | Writes to | Required props |
|---|---|---|
pageview | visitors, page_views, user_events, Analytics Engine | url |
event | user_events (event_type='custom') | name (the custom event name) |
identify | identified_users (UPSERT on (website_id, visitor_id)) | email and/or any of: name, customer_id, phone, company, plus arbitrary custom attributes |
goal | goal_completions (after looking up the goal by name) | name (must match an active goal in app.zenovay.com → Goals); optional value |
purchase | payment_events | amount (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:
| Tier | Per-minute | Per-month |
|---|---|---|
| Free | 10 | 1,000 |
| Pro | 30 | 10,000 |
| Scale | 60 | 100,000 |
| Enterprise | 120 | 1,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.clientIpis present, it is hashed with daily-salted SHA-256 before persistence. If absent, the valueunknownis stored as a sentinel. - GPC is honoured. A request with
Sec-GPC: 1rejects every event in the batch with reasongpc_opted_out. No row is written. - Per-event consent. An event with
consent: 'opt-out'is rejected individually with reasonconsent_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 -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
# Example: from a Sidekiq job or Rails controller
track_event_zenovay('newsletter_subscribed', visitor_id, source: 'footer_form')Rejection reasons
reason | When it happens |
|---|---|
gpc_opted_out | The request had Sec-GPC: 1. The whole batch is rejected. |
consent_opted_out | The single event had consent: 'opt-out'. |
invalid_ts | ts is missing, non-numeric, or beyond ±24 hours of server time. |
invalid_event_type | type is not one of the five supported values. |
invalid_idempotency_key | idempotencyKey is empty or longer than 128 characters. |
invalid_visitor_id | Required visitorId missing, or not UUID-shaped for a pageview. |
invalid_session_id | Required sessionId missing, or not UUID-shaped for a pageview. |
unknown_goal | props.name doesn't match any active custom goal on that website. |
invalid_payment_provider | props.payment_provider is not one of stripe/lemonsqueezy/polar/server. |
internal_error | Unexpected 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:
type | Where it shows up |
|---|---|
pageview | Pages tab, real-time activity, visitor session timeline |
event | Custom events list, visitor timeline (filter by event_name to find a specific event) |
identify | Identified Users page, visitor profile detail |
goal | Goals tab, funnel-completion stats |
purchase | Revenue 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
pageviewevents, that UUID must be a real RFC-4122 UUID.