Suivi côté serveur
Le point de terminaison POST /api/v1/events permet à votre backend d'envoyer directement des événements analytiques à Zenovay. Utilisez-le pour les achats capturés depuis un webhook de paiement, les événements d'identification provenant de votre service d'authentification, ou tout autre cas où le navigateur du client n'est pas la source de vérité.
Les événements arrivent dans les mêmes tables Postgres que ceux du tracker navigateur, mais ils sont marqués avec source: 'server' afin d'apparaître dans les tableaux de bord aux côtés du flux navigateur. Les rapports de réconciliation dans app.zenovay.com utilisent ce drapeau pour distinguer les deux flux.
Le tracker navigateur et le point de terminaison côté serveur sont complémentaires — non interchangeables. Utilisez les deux. Le tracker navigateur capture les pages vues et l'engagement ; le point de terminaison serveur capture les événements faisant foi que l'on ne peut pas confier au navigateur (achats, identification, activité hors site).
Point de terminaison
/api/v1/eventsIngère un lot pouvant contenir jusqu'à 1 000 événements côté serveur
URL : https://api.zenovay.com/api/v1/events
En-têtes :
Authorization: Bearer zv_...— requisContent-Type: application/json— requisSec-GPC: 1— facultatif ; lorsqu'il est présent, le lot entier est rejeté avec la raisongpc_opted_out
Authentification
Authentifiez-vous avec une clé API zv_* émise depuis app.zenovay.com → Paramètres → Clés API. La clé autorise l'appelant pour une seule équipe, et soit pour un seul site web (portée site_access), soit pour tous les sites web de cette équipe (portée full_access).
La clé est hachée avec SHA-256 à la périphérie et validée contre website_api_keys. Des limites mensuelles et par minute spécifiques au palier s'appliquent (voir Limites de débit).
Corps de la requête
{
"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"
}
}
| Champ | Type | Requis | Notes |
|---|---|---|---|
trackingCode | string | oui | Le code de suivi du site web, disponible dans app.zenovay.com → paramètres du site web. |
events | ServerEvent[] | oui | 1 à 1 000 événements. |
events[i].type | pageview | event | identify | goal | purchase | oui | Taxonomie d'événement — voir ci-dessous. |
events[i].ts | number | oui | Millisecondes Unix. Doit se situer dans une plage de ±24 heures par rapport à l'heure du serveur, sinon rejeté avec invalid_ts. |
events[i].sessionId | string | conditionnel | Requis pour pageview et goal. UUID requis pour pageview (la colonne est de type UUID). |
events[i].visitorId | string | conditionnel | Requis pour pageview, identify et goal. UUID requis pour pageview. |
events[i].props | object | oui | Charge utile spécifique au type — voir ci-dessous. |
events[i].idempotencyKey | string | recommandé | Unique par trackingCode. Empêche les écritures dupliquées pendant 24 heures. 128 caractères au maximum. |
events[i].consent | opt-in | opt-out | unknown | facultatif | opt-out ne rejette que cet événement avec la raison consent_opted_out. |
serverContext.clientIp | string | facultatif | Hachée (SHA-256 avec un sel quotidien) avant toute persistance. Le texte en clair n'est jamais stocké. |
Types d'événements
type | Écrit dans | props requises |
|---|---|---|
pageview | visitors, page_views, user_events, Analytics Engine | url |
event | user_events (event_type='custom') | name (le nom de l'événement personnalisé) |
identify | identified_users (UPSERT sur (website_id, visitor_id)) | email et/ou l'un de : name, customer_id, phone, company, ainsi que des attributs personnalisés arbitraires |
goal | goal_completions (après recherche de l'objectif par nom) | name (doit correspondre à un objectif actif dans app.zenovay.com → Objectifs) ; value facultatif |
purchase | payment_events | amount (nombre), currency facultatif (par défaut USD), payment_provider (stripe/lemonsqueezy/polar/server, par défaut server), status (par défaut succeeded) |
Réponse
Le point de terminaison renvoie toujours HTTP 200 lorsque l'authentification et la validation réussissent ; les résultats par événement sont indiqués dans le corps, ce qui vous permet de mélanger événements acceptés et rejetés dans un même lot.
{
"acceptedCount": 4,
"dedupedCount": 1,
"rejectedCount": 0,
"errors": []
}
Lorsque des événements sont rejetés, errors liste chaque échec avec l'index d'origine :
{
"acceptedCount": 0,
"dedupedCount": 0,
"rejectedCount": 1,
"errors": [
{ "index": 0, "reason": "invalid_ts", "message": "ts is missing or beyond ±24h skew tolerance" }
]
}
Idempotence
Transmettez une idempotencyKey sur chaque événement. Pendant les 24 heures suivantes, tout événement portant le même (trackingCode, idempotencyKey) renvoie dedupedCount et n'est pas écrit une seconde fois. Les clés de plus de 128 caractères ou vides sont rejetées avec la raison invalid_idempotency_key.
L'idempotence est appliquée à la fois à la périphérie (cache Cloudflare KV) et au niveau de la base de données (index uniques partiels sur (website_id, idempotency_key) pour visitors, page_views, user_events). La couche KV est rapide ; la couche base de données capte le rare cas limite où deux événements partageant la même clé arrivent dans des colos Cloudflare différents en quelques millisecondes.
Limites de débit
Les limites par minute et par mois sont héritées de votre palier d'abonnement :
| Palier | Par minute | Par mois |
|---|---|---|
| Free | 10 | 1 000 |
| Pro | 30 | 10 000 |
| Scale | 60 | 100 000 |
| Enterprise | 120 | 1 000 000 |
Lorsque vous atteignez une limite, le point de terminaison renvoie HTTP 429 avec les en-têtes de réponse standards X-RateLimit-* et X-Usage-*. Un backoff exponentiel est recommandé.
Confidentialité
- Les adresses IP ne sont jamais stockées en clair. Si
serverContext.clientIpest présent, il est haché avec SHA-256 et un sel quotidien avant la persistance. S'il est absent, la valeur sentinelleunknownest stockée. - GPC est respecté. Une requête contenant
Sec-GPC: 1rejette tous les événements du lot avec la raisongpc_opted_out. Aucune ligne n'est écrite. - Consentement par événement. Un événement avec
consent: 'opt-out'est rejeté individuellement avec la raisonconsent_opted_out; les autres événements du même lot peuvent toujours être acceptés. - Le consentement côté serveur relève de la responsabilité du client. Le tracker navigateur est sans cookie et licite avant tout consentement au regard d'ePrivacy/du RGPD ; l'ingestion côté serveur s'inscrit par définition dans la politique de consentement de votre propre application.
Exemples
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
# Exemple : depuis un job Sidekiq ou un contrôleur Rails
track_event_zenovay('newsletter_subscribed', visitor_id, source: 'footer_form')Raisons de rejet
reason | Quand cela se produit |
|---|---|
gpc_opted_out | La requête contenait Sec-GPC: 1. Le lot entier est rejeté. |
consent_opted_out | L'événement unique avait consent: 'opt-out'. |
invalid_ts | ts est manquant, non numérique, ou s'écarte de plus de ±24 heures de l'heure du serveur. |
invalid_event_type | type ne correspond à aucune des cinq valeurs prises en charge. |
invalid_idempotency_key | idempotencyKey est vide ou dépasse 128 caractères. |
invalid_visitor_id | visitorId requis manquant, ou ne respectant pas le format UUID pour un pageview. |
invalid_session_id | sessionId requis manquant, ou ne respectant pas le format UUID pour un pageview. |
unknown_goal | props.name ne correspond à aucun objectif personnalisé actif sur ce site web. |
invalid_payment_provider | props.payment_provider ne fait pas partie de stripe/lemonsqueezy/polar/server. |
internal_error | Erreur de base de données ou d'exécution inattendue. Une nouvelle tentative avec la même idempotencyKey est sans risque. |
Où ces événements apparaissent dans le tableau de bord
Chaque type d'événement atterrit sur une surface Zenovay spécifique :
type | Où il apparaît |
|---|---|
pageview | Onglet Pages, activité en temps réel, chronologie de session du visiteur |
event | Liste des événements personnalisés, chronologie du visiteur (filtrer par event_name pour un événement précis) |
identify | Page Utilisateurs identifiés, détail du profil du visiteur |
goal | Onglet Objectifs, statistiques de complétion d'entonnoir |
purchase | Tableau de bord Revenus, attribution des revenus, profil de l'utilisateur identifié |
Chaque clé API dispose aussi de son propre panneau Activité côté serveur dans app.zenovay.com → Paramètres → Clés API → [cliquer sur une clé]. Il affiche le nombre d'événements acceptés sur 24 h et 7 j pour la portée des sites web de la clé, les principaux types d'événements et les 20 événements les plus récents.
Seuls les événements acceptés y sont listés. Les motifs de rejet sont retournés de façon synchrone dans le corps de la réponse de POST /api/v1/events — consultez-les pour déboguer votre expéditeur.
Ce que ce point de terminaison NE fait PAS
- Il ne crée pas d'objectifs personnalisés à la volée. Définissez-les d'abord dans app.zenovay.com → Objectifs.
- Il ne rapproche pas l'activité hors ligne des visiteurs en ligne. Cette fonctionnalité figure dans la feuille de route en tant que point de terminaison distinct.
- Il ne remplace pas le tracker navigateur. Les pages vues envoyées depuis votre backend ne contiendront ni données d'appareil, ni système d'exploitation, ni navigateur, ni géolocalisation, sauf si vous les fournissez dans
props. - Il ne génère pas d'identifiants visiteurs pour vous. Utilisez l'UUID dont vous disposez déjà côté visiteur ; pour les événements
pageview, cet UUID doit être un véritable UUID RFC-4122.