Aller au contenu principal
10 min de lecture

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

POST/api/v1/events

Ingè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_... — requis
  • Content-Type: application/json — requis
  • Sec-GPC: 1 — facultatif ; lorsqu'il est présent, le lot entier est rejeté avec la raison gpc_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"
  }
}
ChampTypeRequisNotes
trackingCodestringouiLe code de suivi du site web, disponible dans app.zenovay.com → paramètres du site web.
eventsServerEvent[]oui1 à 1 000 événements.
events[i].typepageview | event | identify | goal | purchaseouiTaxonomie d'événement — voir ci-dessous.
events[i].tsnumberouiMillisecondes Unix. Doit se situer dans une plage de ±24 heures par rapport à l'heure du serveur, sinon rejeté avec invalid_ts.
events[i].sessionIdstringconditionnelRequis pour pageview et goal. UUID requis pour pageview (la colonne est de type UUID).
events[i].visitorIdstringconditionnelRequis pour pageview, identify et goal. UUID requis pour pageview.
events[i].propsobjectouiCharge utile spécifique au type — voir ci-dessous.
events[i].idempotencyKeystringrecommandéUnique par trackingCode. Empêche les écritures dupliquées pendant 24 heures. 128 caractères au maximum.
events[i].consentopt-in | opt-out | unknownfacultatifopt-out ne rejette que cet événement avec la raison consent_opted_out.
serverContext.clientIpstringfacultatifHachée (SHA-256 avec un sel quotidien) avant toute persistance. Le texte en clair n'est jamais stocké.

Types d'événements

typeÉcrit dansprops requises
pageviewvisitors, page_views, user_events, Analytics Engineurl
eventuser_events (event_type='custom')name (le nom de l'événement personnalisé)
identifyidentified_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
goalgoal_completions (après recherche de l'objectif par nom)name (doit correspondre à un objectif actif dans app.zenovay.com → Objectifs) ; value facultatif
purchasepayment_eventsamount (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 :

PalierPar minutePar mois
Free101 000
Pro3010 000
Scale60100 000
Enterprise1201 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.clientIp est présent, il est haché avec SHA-256 et un sel quotidien avant la persistance. S'il est absent, la valeur sentinelle unknown est stockée.
  • GPC est respecté. Une requête contenant Sec-GPC: 1 rejette tous les événements du lot avec la raison gpc_opted_out. Aucune ligne n'est écrite.
  • Consentement par événement. Un événement avec consent: 'opt-out' est rejeté individuellement avec la raison consent_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 — 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 — achat depuis un webhook 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 — identify après inscriptionPython
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 — événement personnalisé depuis un job Rails en arrière-planRuby
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

reasonQuand cela se produit
gpc_opted_outLa requête contenait Sec-GPC: 1. Le lot entier est rejeté.
consent_opted_outL'événement unique avait consent: 'opt-out'.
invalid_tsts est manquant, non numérique, ou s'écarte de plus de ±24 heures de l'heure du serveur.
invalid_event_typetype ne correspond à aucune des cinq valeurs prises en charge.
invalid_idempotency_keyidempotencyKey est vide ou dépasse 128 caractères.
invalid_visitor_idvisitorId requis manquant, ou ne respectant pas le format UUID pour un pageview.
invalid_session_idsessionId requis manquant, ou ne respectant pas le format UUID pour un pageview.
unknown_goalprops.name ne correspond à aucun objectif personnalisé actif sur ce site web.
invalid_payment_providerprops.payment_provider ne fait pas partie de stripe/lemonsqueezy/polar/server.
internal_errorErreur 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 :

typeOù il apparaît
pageviewOnglet Pages, activité en temps réel, chronologie de session du visiteur
eventListe des événements personnalisés, chronologie du visiteur (filtrer par event_name pour un événement précis)
identifyPage Utilisateurs identifiés, détail du profil du visiteur
goalOnglet Objectifs, statistiques de complétion d'entonnoir
purchaseTableau 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.
Cette page vous a-t-elle été utile ?