Server-Side-Tracking
Über den Endpunkt POST /api/v1/events kann Ihr Backend Analyse-Events direkt an Zenovay senden. Verwenden Sie ihn für Käufe, die aus einem Zahlungs-Webhook stammen, für Identifizierungs-Events aus Ihrem Authentifizierungsdienst oder für alles, bei dem der Browser des Clients nicht die maßgebliche Datenquelle ist.
Events landen in denselben Postgres-Tabellen wie Events des Browser-Trackers, werden jedoch mit source: 'server' markiert, sodass sie in den Dashboards parallel zum Browser-Stream erscheinen. Abgleichsberichte in app.zenovay.com nutzen dieses Flag, um die beiden Streams zu unterscheiden.
Der Browser-Tracker und der serverseitige Endpunkt ergänzen sich — sie sind nicht austauschbar. Verwenden Sie beide. Der Browser-Tracker erfasst Seitenaufrufe und Engagement; der Server-Endpunkt erfasst Events des Systems der Wahrheit, denen man Browser nicht zumuten kann (Käufe, Identifizierung, Aktivität außerhalb der Website).
Endpunkt
/api/v1/eventsNimmt einen Batch von bis zu 1.000 serverseitigen Events entgegen
URL: https://api.zenovay.com/api/v1/events
Header:
Authorization: Bearer zv_...— erforderlichContent-Type: application/json— erforderlichSec-GPC: 1— optional; wenn vorhanden, wird der gesamte Batch mit dem Grundgpc_opted_outabgelehnt
Authentifizierung
Authentifizieren Sie sich mit einem zv_*-API-Schlüssel, der in app.zenovay.com → Einstellungen → API-Schlüssel ausgestellt wird. Der Schlüssel berechtigt den Aufrufer für ein einzelnes Team und entweder für eine einzelne Website (Scope site_access) oder für jede Website dieses Teams (Scope full_access).
Der Schlüssel wird am Edge mit SHA-256 gehasht und gegen website_api_keys validiert. Es gelten tarifabhängige monatliche und minütliche Limits (siehe 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"
}
}
| Feld | Typ | Erforderlich | Hinweise |
|---|---|---|---|
trackingCode | string | ja | Der Tracking-Code der Website, zu finden unter app.zenovay.com → Website-Einstellungen. |
events | ServerEvent[] | ja | 1–1.000 Events. |
events[i].type | pageview | event | identify | goal | purchase | ja | Event-Taxonomie — siehe unten. |
events[i].ts | number | ja | Unix-Millisekunden. Muss innerhalb von ±24 Stunden zur Serverzeit liegen, andernfalls Ablehnung mit invalid_ts. |
events[i].sessionId | string | bedingt | Erforderlich für pageview und goal. UUID erforderlich für pageview (die Spalte ist vom Typ UUID). |
events[i].visitorId | string | bedingt | Erforderlich für pageview, identify und goal. UUID erforderlich für pageview. |
events[i].props | object | ja | Typabhängiger Payload — siehe unten. |
events[i].idempotencyKey | string | empfohlen | Eindeutig pro trackingCode. Verhindert doppelte Schreibvorgänge für 24 Stunden. Maximal 128 Zeichen. |
events[i].consent | opt-in | opt-out | unknown | optional | opt-out lehnt ausschließlich dieses eine Event mit dem Grund consent_opted_out ab. |
serverContext.clientIp | string | optional | Wird vor jeder Persistierung gehasht (täglich gesalzenes SHA-256). Klartext wird niemals gespeichert. |
Event-Typen
type | Schreibt nach | Erforderliche props |
|---|---|---|
pageview | visitors, page_views, user_events, Analytics Engine | url |
event | user_events (event_type='custom') | name (Name des benutzerdefinierten Events) |
identify | identified_users (UPSERT auf (website_id, visitor_id)) | email und/oder eines von: name, customer_id, phone, company, dazu beliebige benutzerdefinierte Attribute |
goal | goal_completions (nach Lookup des Ziels über den Namen) | name (muss zu einem aktiven Ziel in app.zenovay.com → Ziele passen); optional value |
purchase | payment_events | amount (Zahl), optional currency (Standard USD), payment_provider (stripe/lemonsqueezy/polar/server, Standard server), status (Standard succeeded) |
Response
Der Endpunkt antwortet immer mit HTTP 200, sobald Authentifizierung und Validierung erfolgreich sind; die Ergebnisse pro Event werden im Body gemeldet, sodass Sie erfolgreiche und abgelehnte Events in einem Batch mischen können.
{
"acceptedCount": 4,
"dedupedCount": 1,
"rejectedCount": 0,
"errors": []
}
Wenn Events abgelehnt werden, listet errors jeden Fehlschlag mit dem ursprünglichen Index auf:
{
"acceptedCount": 0,
"dedupedCount": 0,
"rejectedCount": 1,
"errors": [
{ "index": 0, "reason": "invalid_ts", "message": "ts is missing or beyond ±24h skew tolerance" }
]
}
Idempotenz
Übergeben Sie für jedes Event einen idempotencyKey. Innerhalb der nächsten 24 Stunden liefert jedes Event mit demselben (trackingCode, idempotencyKey) ein dedupedCount zurück und wird nicht ein zweites Mal geschrieben. Schlüssel länger als 128 Zeichen oder leere Schlüssel werden mit dem Grund invalid_idempotency_key abgelehnt.
Idempotenz wird sowohl am Edge (Cloudflare-KV-Cache) als auch auf Datenbankebene erzwungen (partielle Unique-Indizes auf (website_id, idempotency_key) für visitors, page_views, user_events). Die KV-Schicht ist schnell; die Datenbankschicht fängt den seltenen Edge Case ab, in dem zwei Events mit demselben Schlüssel innerhalb von Millisekunden in unterschiedlichen Cloudflare-Colos eintreffen.
Rate Limits
Minütliche und monatliche Limits ergeben sich aus Ihrem Abonnement-Tarif:
| Tarif | Pro Minute | Pro Monat |
|---|---|---|
| Free | 10 | 1.000 |
| Pro | 30 | 10.000 |
| Scale | 60 | 100.000 |
| Enterprise | 120 | 1.000.000 |
Wenn Sie ein Limit erreichen, antwortet der Endpunkt mit HTTP 429 sowie den Standard-Antwortheadern X-RateLimit-* und X-Usage-*. Exponentielles Backoff wird empfohlen.
Datenschutz
- IP-Adressen werden niemals im Klartext gespeichert. Ist
serverContext.clientIpvorhanden, wird die Adresse vor der Persistierung mit täglich gesalzenem SHA-256 gehasht. Fehlt sie, wird der Sentinel-Wertunknowngespeichert. - GPC wird respektiert. Eine Anfrage mit
Sec-GPC: 1lehnt jedes Event im Batch mit dem Grundgpc_opted_outab. Es wird keine Zeile geschrieben. - Einwilligung pro Event. Ein Event mit
consent: 'opt-out'wird einzeln mit dem Grundconsent_opted_outabgelehnt; andere Events desselben Batches können dennoch akzeptiert werden. - Die serverseitige Einwilligung liegt in der Verantwortung des Kunden. Der Browser-Tracker ist cookiefrei und auch ohne Einwilligung nach ePrivacy/DSGVO rechtmäßig; die serverseitige Erfassung erfolgt definitionsgemäß im Rahmen der Einwilligungslogik Ihrer eigenen Anwendung.
Beispiele
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
# Beispiel: aus einem Sidekiq-Job oder Rails-Controller
track_event_zenovay('newsletter_subscribed', visitor_id, source: 'footer_form')Ablehnungsgründe
reason | Wann er auftritt |
|---|---|
gpc_opted_out | Die Anfrage enthielt Sec-GPC: 1. Der gesamte Batch wird abgelehnt. |
consent_opted_out | Das einzelne Event hatte consent: 'opt-out'. |
invalid_ts | ts fehlt, ist nicht numerisch oder weicht um mehr als ±24 Stunden von der Serverzeit ab. |
invalid_event_type | type gehört nicht zu den fünf unterstützten Werten. |
invalid_idempotency_key | idempotencyKey ist leer oder länger als 128 Zeichen. |
invalid_visitor_id | Erforderlicher visitorId fehlt oder hat bei einem pageview keine UUID-Form. |
invalid_session_id | Erforderlicher sessionId fehlt oder hat bei einem pageview keine UUID-Form. |
unknown_goal | props.name passt zu keinem aktiven benutzerdefinierten Ziel auf dieser Website. |
invalid_payment_provider | props.payment_provider ist keiner von stripe/lemonsqueezy/polar/server. |
internal_error | Unerwarteter Datenbank- oder Laufzeitfehler. Ein erneuter Versuch mit demselben idempotencyKey ist gefahrlos möglich. |
Wo diese Ereignisse im Dashboard erscheinen
Jeder Ereignistyp landet auf einer bestimmten Zenovay-Oberfläche:
type | Wo es erscheint |
|---|---|
pageview | Seiten-Tab, Echtzeit-Aktivität, Besucher-Sitzungstimeline |
event | Liste der benutzerdefinierten Ereignisse, Besucher-Timeline (nach event_name filtern, um ein bestimmtes Ereignis zu finden) |
identify | Seite „Identifizierte Nutzer", Besucherprofil-Detail |
goal | Ziele-Tab, Statistiken zu Funnel-Abschlüssen |
purchase | Umsatz-Dashboard, Umsatzattribution, Profil identifizierter Nutzer |
Jeder API-Schlüssel hat zudem ein eigenes Serverseitige Aktivität-Panel unter app.zenovay.com → Einstellungen → API-Schlüssel → [auf einen Schlüssel klicken]. Es zeigt für den Website-Bereich des Schlüssels die Zahl akzeptierter Ereignisse der letzten 24 Stunden und 7 Tage, die häufigsten Ereignistypen sowie die 20 zuletzt eingegangenen Ereignisse.
Dort werden nur akzeptierte Ereignisse aufgelistet. Ablehnungsgründe werden synchron im Antwort-Body von POST /api/v1/events zurückgegeben — prüfen Sie diese beim Debuggen Ihres Senders.
Was dieser Endpunkt NICHT tut
- Er erstellt keine benutzerdefinierten Ziele spontan. Definieren Sie diese zuerst unter app.zenovay.com → Ziele.
- Er ordnet keine Offline-Aktivität Online-Besuchern zu. Diese Funktion steht als separater Endpunkt auf der Roadmap.
- Er ersetzt nicht den Browser-Tracker. Pageviews aus Ihrem Backend enthalten keine Geräte-, Betriebssystem-, Browser- oder Geo-Daten, sofern Sie diese nicht in
propsmitliefern. - Er erzeugt keine Visitor-IDs für Sie. Verwenden Sie die UUID, die Ihnen für den Besucher bereits vorliegt; bei
pageview-Events muss diese UUID eine echte RFC-4122-UUID sein.