diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 4707b117e0876527bae5a1c6551f3c9f51e0285d..b5457a0c4964d69b96fd9a757d314a5b0b05bdf2 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -24,6 +24,8 @@ stages:
   artifacts:
     paths:
       - $COMPONENT/*/target/*.jar
+  tags:
+    - ci
 
 .test_template:
   stage: test
@@ -36,7 +38,8 @@ stages:
       junit:
         - "$COMPONENT/*/target/surefire-reports/TEST-*.xml"
         - "$COMPONENT/*/target/failsafe-reports/TEST-*.xml"
-
+  tags:
+    - ci
 
 .docker_template:
   image: registry.gitlab.inria.fr/stemcovid19/tac-server/docker-image/docker-git:latest
@@ -58,6 +61,8 @@ stages:
   only:
     - develop
     - tags
+  tags:
+    - ci
 
 build_robert-server:
   extends: .build_template
@@ -84,6 +89,16 @@ build_submission-code-server:
   variables:
     COMPONENT: "submission-code-server"
 
+build_clea:
+  extends: .build_template
+  variables:
+    COMPONENT: "clea"
+
+build_analytics-server:
+  extends: .build_template
+  variables:
+    COMPONENT: "analytics-server"
+
 test_tac-warning:
   extends: .test_template
   needs: ["build_tac-warning"]
@@ -110,6 +125,18 @@ test_submission-code-server:
     COMPONENT: "submission-code-server"
   allow_failure: true
 
+test_clea:
+  extends: .test_template
+  needs: ["build_clea"]
+  variables:
+    COMPONENT: "clea"
+
+test_analytics-server:
+  extends: .test_template
+  needs: [ "build_analytics-server" ]
+  variables:
+    COMPONENT: "analytics-server"
+
 docker-robert-crypto-grpc-server:
   extends: .docker_template
   needs: [ "build_robert-server" ]
@@ -193,6 +220,7 @@ integration_tests:
     - echo "ROBERT_JWT_PUBLIC_KEY=${CI_ROBERT_JWT_PUBLIC_KEY}" >> .env
     - echo "ROBERT_JWT_TOKEN_DECLARE_PUBLIC_KID=${CI_ROBERT_JWT_TOKEN_DECLARE_PUBLIC_KID}" >> .env
     - echo "ROBERT_JWT_TOKEN_DECLARE_PRIVATE_KEY=${CI_ROBERT_JWT_TOKEN_DECLARE_PRIVATE_KEY}" >> .env
+    - echo "ROBERT_JWT_TOKEN_ANALYTICS_PRIVATE_KEY=${CI_ROBERT_JWT_TOKEN_ANALYTICS_PRIVATE_KEY}" >> .env
     - cat .env
     - docker login -u $CI_REGISTRY_USER --password-stdin  $CI_REGISTRY <<< "$CI_REGISTRY_PASSWORD"
     - echo $PWD
@@ -258,3 +286,15 @@ deploy-tac-warning:
   needs: ["test_tac-warning"]
   variables:
     COMPONENT: "tac-warning"
+
+deploy-clea:
+  extends: .deploy_template
+  needs: ["test_clea"]
+  variables:
+    COMPONENT: "clea"
+
+deploy-analytics-server:
+  extends: .deploy_template
+  needs: ["test_analytics-server"]
+  variables:
+    COMPONENT: "analytics-server"
\ No newline at end of file
diff --git a/analytics-server/README.md b/analytics-server/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..a9d39474c41fbd62e2d1d0b1056a03bc81d64669
--- /dev/null
+++ b/analytics-server/README.md
@@ -0,0 +1,65 @@
+# Introduction
+
+Ce projet gitlab.inria.fr est un des composants de la solution plus
+globale [StopCovid](https://gitlab.inria.fr/stopcovid19/accueil/-/blob/master/README.md).
+
+Ce composant gère la fonctionnalité appelée "analytics", c'est une fonctionnalité qui permet de recevoir de la part des
+applications mobiles leurs statistiques d'usage (quel model de téléphone, quelle version d'OS, à quelle date a été
+effectué le dernier status, la liste des erreurs rencontrées...)
+
+# Architecture générale
+
+![](analytics.png)
+
+L'application repose sur une application springboot en frontal. Celle-ci traite la requête provenant du terminal de
+l'utilisateur final. Les principaux traitements effectués par cette application sont :
+
+- validation du token JWT fourni par l'application robert, à noter qu'un token ne peut être utilisé qu'une unique fois
+  pour soumettre des analytics. Une base de donnée mongodb est utilisée pour conserver la liste des tokens déjà
+  utilisée.
+- validation du contenu de la requête, on vérifie la structure de la requête vis à vis du contrat d'interface
+- envoi des analytics dans un topic kafka.
+
+Par la suite, le topic kafka est consommé par logstash qui repousse les analytics dans un elasticsearch. Les analytics
+sont ensuite analysés grâce à un kibana.
+
+note : le fichier source du diagramme est fourni [ici](analytics.drawio)
+
+## contrat d'interface
+
+le contrat d'interface du service rest exposé est disponible au format
+openapi [anaylics_openapi](src/main/doc/openid_analytics.yml)
+
+# Environment de développement
+
+Si ce n'est déjà fait, se logguer, sur la registry docker de l'inria.
+
+    docker login registry.gitlab.inria.fr
+
+Pour fonctionner, cette application à besoin à minima :
+
+- De mongodb
+- De kafka
+
+La stack complète d'outil tel que décrit dans le schéma d'architecture générale afin de manipuler les analytics qui
+auront été stockés dans l'elasticsearch
+
+Ces différents services sont disponibles dans un environnement docker lançable par :
+
+    docker-compose -f ../environment-setup/dev/compose/docker-compose-analytics-server.yaml up -d
+
+Les services dockerisés ne sont utilisés que pour pouvoir lancer l'application par elle-même. Les tests untaires
+utilisent des services embarqués (embedded mongodb et kafka).
+
+## Appel rest
+
+Des appels rest peuvent être effectué sur l'application en utilisant la collection postman
+fournie [postmail](src/main/doc/TAC-analytics.postman_collection.json)
+
+## Spécificité de la configuration sous Windows 10
+
+Dans le fichier etc/hosts (c:\windows\system32\drivers\etc\hosts) ajouter l'entrée suivante :
+
+    127.0.0.1 docker-desktop
+
+Dans l'application docker desktop settings, placer la quantité de mémoire à 10G.
\ No newline at end of file
diff --git a/analytics-server/analytics.drawio b/analytics-server/analytics.drawio
new file mode 100644
index 0000000000000000000000000000000000000000..819e1af96dcfdc791a5dff7377c10a5ae66095b9
--- /dev/null
+++ b/analytics-server/analytics.drawio
@@ -0,0 +1 @@
+<mxfile host="app.diagrams.net" modified="2021-03-23T10:31:31.463Z" agent="5.0 (X11)" etag="14338BJJV65bln0yTPUD" version="14.4.6" type="device"><diagram name="Page-1" id="822b0af5-4adb-64df-f703-e8dfc1f81529">7Vtbc6M4Fv41foQSCHF5TCfxTO3MVnVNeqdn+iUlQMZaY0QLfEn/+jnCgAHhxJ3YsaernaoYDpKQzuU7F8kTfLvc/iJpPv+viFk6sVG8neC7iW3byLXhS1GedhQrIO6Okkge17Q94YF/YzUR1dQVj1nRa1gKkZY87xMjkWUsKns0KqXY9JvNRNp/a04TphEeIprq1M88Luc11UJo/+BXxpN5/Wqf1A9CGi0SKVZZ/b6JjWfVZ/d4SZux6vbFnMZi0yHh+wm+lUKUu6vl9palirkN23b9pgeetvOWLCuP6fAFGdFmTR6//JGuv9KP/ufH+N7A9TBrmq5Ys45qtuVTw6FqjUyNgib4w2bOS/aQ00g93YBSAG1eLlO4s+CyKKVYsFuRCln1xm71gSczkZUd+u6j6DxNO/T7O/UH9HpiTJZse3DJVstI0FAmlqyUT9Ck6YAbKdbaabhOTdh0ZN1IaN4Rs205tY7V+pW0o+95DBc1m8dZ7sUBxiEK4sAjhEWRYQUvsxuGAN1nL7O6L5dWvdRNJJY8ah5UEml026olMaVLniqu/MlkTDPaMlzj7ogMDjLc9XCf38S3dX67vkl0jlsNlLyF46NKruv4HyKENQHt84NiPpOwRE0QatkcgOJ3GrL0oyh4yUUGj0JRlmIJDKNFvkOkGd8qOXSF03S+SXmiOpUi16wjExnbSS5Xr1xuE4WxZsRLybdmKTZMPtZzO1raz8r2bcaE3IFwXawbkzsiWZ+cSbD2y9aUC56VTN6vYZVFg1Fd5sW0mLdsPQhktagGWEWQ+gN6OlCRiKmXdtTgoA6N64lYlSnP4O2Nz1MvoXWTdvCh4ojZjEfMjNkavgozAg4+5nOY+SPNYil4/JhLkQup5kDT02iF11hta/HuCMJ6ulI0nufkSoF/KsXFlYIM3YCNL6sUzk+luLhSBEOlsC6MFOSnUlxcKYbuw/EurBSuphT/gxisuLbocFVN6grCQjewhmbdhvcdGTqWiUZCQ3yu0NAfsW03LZXF8jVcJuqyTQNuQKGfQCJFJxvYtYaXdzqMjNGQQjmkDLtelwJdf3rh4OCy6cUxyXoW36gilMLVlBaFYlRXTmzLy78U70yEm/u/q3tiB/X93bZmbnXz1Ln5yCSHpSgBVbTd61msVbS0lL0QKxmxZ9bWlNqoTFjd9UC1QpdYRx5kpHTS0CRLacnX/bmOCal+w0fla/cKAZMZBpGoP8ZukXW3buVrOJI/HMkbjLRjgzZSpTXtwt9QgECaJv3C1AT/8/lTVe9csEzVLiXYvVrktChpudJ9jpJ8BRd9JdM8+xAdljyO1RgfJCv4NxpW4ymFqsKbigfkw4Q8W3Wr669150lb1uwq32ErOmj2yLT9YQxgvU1/miYQzxTsPPI8omp6PDIE2O4hg+d4340MIAL5VAONRRrCbjzHtxvCfsDq7ql7dxqwOVjy7CLNYY99KbDxh8mJg14LNs5wJPLOYKNXxSC6qAyjE+VsOHC1hZ4fAWes5+MLA5k4cJ2+bOyrRxq9cPEbnS2oJrLRlK+XnL4yl9SE3k1yB6El3RTY/Lpiq5Fk9y648RzVCZrGnO33guow9aBCfM92jz3Y7nHAv+i5iIfH9h8c/7Dc37b9cESd4VhvAVmU2/cWTuC8wVv0PQV5Pz/xXNL2oqvYWcWlfIXV7l03aoaH8eSxzsKyXV1j39db6PWO30UC8Wcx15S0MXe+rPbUP1TfN02WisZS1leCTv2Gu3lZqmMAN2rZ9pQvE9ucScbyLIFvoNjI8lGAPLikPIH/C14U8NQACRfK0zEqo7mR1isyFjwEJ2hARpuvQIMNDt6mMAp4P2267BvDhTCKdWKsYU1CGnSZG2rsUtKsyKlBwsC2LBbSIESxb8I0HOQ7vkWwaweBEwRuQCzz/3lyInQjeoKsV9XHsW1PPT26eSdEt36G/G64dkIIaxz2yxjmXhTDHHwyDHP8S2OYXqi5nbNo0UmtVxmPePl0nGaC+cvyWYU9VmEuJl5rePDFxq8VrzYUfmfxNoDSEe8XAXJl+cihjUv7KEk3ZgK51SpUFXpwLyVohBmpws40FtECnE7KQ0mV9BQBcrHpLEAuC0hsRbbnE5dSL2DUCmPfRSimxHJcl3khCqHpt/3Cp8o/meCQzhU829gZC54hj/J1xbWG+fLpDnkcsaH/o5oxHoaHtvNaM9aGwkNEOLcZj6aysQRj+jcZ8YYvOHyJsOAxp1n8lNFlVVKZLprVTKtROyTj5KbqmiToi9Md3XZziWnr6um5ZwsHbT3ZXYqMQ/w8aQ/CHh8YXr+BDsXQHmr4fgMdDuW8t4HqqeBvVb50dfaZSDrjC0D3XRa4ylNBY2VvVT5XpYFq3iYkbyeyOM8jjcns3aNrYlezOAtZY97xXMcVbD396mxmw/Tw9gZ4cN/Nh6H5Q7PJ3TS4OhlvNhuTLkNVKTR4aWaqvjrd5EaNxj2x20hlU1MEHmaqZf4CUlRyQvAlZFhEHjkGGzgmcnQ98G3TDs6lCWNHHn40vHVfTEaOxlttKC22OjfenuB8wdULzPMGXLb8VwuMvDTUmQXW2HlHYJ/q+gKPgd98xhWmIgh4EhGHHXSNaUlDWrDJwR8bXBpuD9RWfaTYKBnt1Fbr5RmZKL6mBuSwq6UCaSEVVrPYaBZrqLOCah/PKOesKGgKs7PJLaRz5OZuwdtxVIxtqF9S5QcaJgYJXRzMUODMHCewTewhgj1wqgRjG/AYw5fjnbDUighkuQN0aCp7PV+PTGfE3bvemTAe66WQT2xbatpUKuL3nUetScfv8Y79Oql/iuwkktC29EZ+weWMANjQL5xOBno9ImZrlop8WcUliGVrLkVW34ks1cuO1yEfKUpaY0ZwKnlpVSTi6FlpC9xvFJiCpvb3kzuU3/9KFd//Aw==</diagram></mxfile>
\ No newline at end of file
diff --git a/analytics-server/analytics.png b/analytics-server/analytics.png
new file mode 100644
index 0000000000000000000000000000000000000000..9123133d96e312380540f1a5d9580606e728964a
Binary files /dev/null and b/analytics-server/analytics.png differ
diff --git a/analytics-server/mvnw b/analytics-server/mvnw
new file mode 100644
index 0000000000000000000000000000000000000000..a16b5431b4c3cab50323a3f558003fd0abd87dad
--- /dev/null
+++ b/analytics-server/mvnw
@@ -0,0 +1,310 @@
+#!/bin/sh
+# ----------------------------------------------------------------------------
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#    https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+# ----------------------------------------------------------------------------
+
+# ----------------------------------------------------------------------------
+# Maven Start Up Batch script
+#
+# Required ENV vars:
+# ------------------
+#   JAVA_HOME - location of a JDK home dir
+#
+# Optional ENV vars
+# -----------------
+#   M2_HOME - location of maven2's installed home dir
+#   MAVEN_OPTS - parameters passed to the Java VM when running Maven
+#     e.g. to debug Maven itself, use
+#       set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000
+#   MAVEN_SKIP_RC - flag to disable loading of mavenrc files
+# ----------------------------------------------------------------------------
+
+if [ -z "$MAVEN_SKIP_RC" ] ; then
+
+  if [ -f /etc/mavenrc ] ; then
+    . /etc/mavenrc
+  fi
+
+  if [ -f "$HOME/.mavenrc" ] ; then
+    . "$HOME/.mavenrc"
+  fi
+
+fi
+
+# OS specific support.  $var _must_ be set to either true or false.
+cygwin=false;
+darwin=false;
+mingw=false
+case "`uname`" in
+  CYGWIN*) cygwin=true ;;
+  MINGW*) mingw=true;;
+  Darwin*) darwin=true
+    # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home
+    # See https://developer.apple.com/library/mac/qa/qa1170/_index.html
+    if [ -z "$JAVA_HOME" ]; then
+      if [ -x "/usr/libexec/java_home" ]; then
+        export JAVA_HOME="`/usr/libexec/java_home`"
+      else
+        export JAVA_HOME="/Library/Java/Home"
+      fi
+    fi
+    ;;
+esac
+
+if [ -z "$JAVA_HOME" ] ; then
+  if [ -r /etc/gentoo-release ] ; then
+    JAVA_HOME=`java-config --jre-home`
+  fi
+fi
+
+if [ -z "$M2_HOME" ] ; then
+  ## resolve links - $0 may be a link to maven's home
+  PRG="$0"
+
+  # need this for relative symlinks
+  while [ -h "$PRG" ] ; do
+    ls=`ls -ld "$PRG"`
+    link=`expr "$ls" : '.*-> \(.*\)$'`
+    if expr "$link" : '/.*' > /dev/null; then
+      PRG="$link"
+    else
+      PRG="`dirname "$PRG"`/$link"
+    fi
+  done
+
+  saveddir=`pwd`
+
+  M2_HOME=`dirname "$PRG"`/..
+
+  # make it fully qualified
+  M2_HOME=`cd "$M2_HOME" && pwd`
+
+  cd "$saveddir"
+  # echo Using m2 at $M2_HOME
+fi
+
+# For Cygwin, ensure paths are in UNIX format before anything is touched
+if $cygwin ; then
+  [ -n "$M2_HOME" ] &&
+    M2_HOME=`cygpath --unix "$M2_HOME"`
+  [ -n "$JAVA_HOME" ] &&
+    JAVA_HOME=`cygpath --unix "$JAVA_HOME"`
+  [ -n "$CLASSPATH" ] &&
+    CLASSPATH=`cygpath --path --unix "$CLASSPATH"`
+fi
+
+# For Mingw, ensure paths are in UNIX format before anything is touched
+if $mingw ; then
+  [ -n "$M2_HOME" ] &&
+    M2_HOME="`(cd "$M2_HOME"; pwd)`"
+  [ -n "$JAVA_HOME" ] &&
+    JAVA_HOME="`(cd "$JAVA_HOME"; pwd)`"
+fi
+
+if [ -z "$JAVA_HOME" ]; then
+  javaExecutable="`which javac`"
+  if [ -n "$javaExecutable" ] && ! [ "`expr \"$javaExecutable\" : '\([^ ]*\)'`" = "no" ]; then
+    # readlink(1) is not available as standard on Solaris 10.
+    readLink=`which readlink`
+    if [ ! `expr "$readLink" : '\([^ ]*\)'` = "no" ]; then
+      if $darwin ; then
+        javaHome="`dirname \"$javaExecutable\"`"
+        javaExecutable="`cd \"$javaHome\" && pwd -P`/javac"
+      else
+        javaExecutable="`readlink -f \"$javaExecutable\"`"
+      fi
+      javaHome="`dirname \"$javaExecutable\"`"
+      javaHome=`expr "$javaHome" : '\(.*\)/bin'`
+      JAVA_HOME="$javaHome"
+      export JAVA_HOME
+    fi
+  fi
+fi
+
+if [ -z "$JAVACMD" ] ; then
+  if [ -n "$JAVA_HOME"  ] ; then
+    if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+      # IBM's JDK on AIX uses strange locations for the executables
+      JAVACMD="$JAVA_HOME/jre/sh/java"
+    else
+      JAVACMD="$JAVA_HOME/bin/java"
+    fi
+  else
+    JAVACMD="`which java`"
+  fi
+fi
+
+if [ ! -x "$JAVACMD" ] ; then
+  echo "Error: JAVA_HOME is not defined correctly." >&2
+  echo "  We cannot execute $JAVACMD" >&2
+  exit 1
+fi
+
+if [ -z "$JAVA_HOME" ] ; then
+  echo "Warning: JAVA_HOME environment variable is not set."
+fi
+
+CLASSWORLDS_LAUNCHER=org.codehaus.plexus.classworlds.launcher.Launcher
+
+# traverses directory structure from process work directory to filesystem root
+# first directory with .mvn subdirectory is considered project base directory
+find_maven_basedir() {
+
+  if [ -z "$1" ]
+  then
+    echo "Path not specified to find_maven_basedir"
+    return 1
+  fi
+
+  basedir="$1"
+  wdir="$1"
+  while [ "$wdir" != '/' ] ; do
+    if [ -d "$wdir"/.mvn ] ; then
+      basedir=$wdir
+      break
+    fi
+    # workaround for JBEAP-8937 (on Solaris 10/Sparc)
+    if [ -d "${wdir}" ]; then
+      wdir=`cd "$wdir/.."; pwd`
+    fi
+    # end of workaround
+  done
+  echo "${basedir}"
+}
+
+# concatenates all lines of a file
+concat_lines() {
+  if [ -f "$1" ]; then
+    echo "$(tr -s '\n' ' ' < "$1")"
+  fi
+}
+
+BASE_DIR=`find_maven_basedir "$(pwd)"`
+if [ -z "$BASE_DIR" ]; then
+  exit 1;
+fi
+
+##########################################################################################
+# Extension to allow automatically downloading the maven-wrapper.jar from Maven-central
+# This allows using the maven wrapper in projects that prohibit checking in binary data.
+##########################################################################################
+if [ -r "$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" ]; then
+    if [ "$MVNW_VERBOSE" = true ]; then
+      echo "Found .mvn/wrapper/maven-wrapper.jar"
+    fi
+else
+    if [ "$MVNW_VERBOSE" = true ]; then
+      echo "Couldn't find .mvn/wrapper/maven-wrapper.jar, downloading it ..."
+    fi
+    if [ -n "$MVNW_REPOURL" ]; then
+      jarUrl="$MVNW_REPOURL/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar"
+    else
+      jarUrl="https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar"
+    fi
+    while IFS="=" read key value; do
+      case "$key" in (wrapperUrl) jarUrl="$value"; break ;;
+      esac
+    done < "$BASE_DIR/.mvn/wrapper/maven-wrapper.properties"
+    if [ "$MVNW_VERBOSE" = true ]; then
+      echo "Downloading from: $jarUrl"
+    fi
+    wrapperJarPath="$BASE_DIR/.mvn/wrapper/maven-wrapper.jar"
+    if $cygwin; then
+      wrapperJarPath=`cygpath --path --windows "$wrapperJarPath"`
+    fi
+
+    if command -v wget > /dev/null; then
+        if [ "$MVNW_VERBOSE" = true ]; then
+          echo "Found wget ... using wget"
+        fi
+        if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then
+            wget "$jarUrl" -O "$wrapperJarPath"
+        else
+            wget --http-user=$MVNW_USERNAME --http-password=$MVNW_PASSWORD "$jarUrl" -O "$wrapperJarPath"
+        fi
+    elif command -v curl > /dev/null; then
+        if [ "$MVNW_VERBOSE" = true ]; then
+          echo "Found curl ... using curl"
+        fi
+        if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then
+            curl -o "$wrapperJarPath" "$jarUrl" -f
+        else
+            curl --user $MVNW_USERNAME:$MVNW_PASSWORD -o "$wrapperJarPath" "$jarUrl" -f
+        fi
+
+    else
+        if [ "$MVNW_VERBOSE" = true ]; then
+          echo "Falling back to using Java to download"
+        fi
+        javaClass="$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.java"
+        # For Cygwin, switch paths to Windows format before running javac
+        if $cygwin; then
+          javaClass=`cygpath --path --windows "$javaClass"`
+        fi
+        if [ -e "$javaClass" ]; then
+            if [ ! -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then
+                if [ "$MVNW_VERBOSE" = true ]; then
+                  echo " - Compiling MavenWrapperDownloader.java ..."
+                fi
+                # Compiling the Java class
+                ("$JAVA_HOME/bin/javac" "$javaClass")
+            fi
+            if [ -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then
+                # Running the downloader
+                if [ "$MVNW_VERBOSE" = true ]; then
+                  echo " - Running MavenWrapperDownloader.java ..."
+                fi
+                ("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$MAVEN_PROJECTBASEDIR")
+            fi
+        fi
+    fi
+fi
+##########################################################################################
+# End of extension
+##########################################################################################
+
+export MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"}
+if [ "$MVNW_VERBOSE" = true ]; then
+  echo $MAVEN_PROJECTBASEDIR
+fi
+MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS"
+
+# For Cygwin, switch paths to Windows format before running java
+if $cygwin; then
+  [ -n "$M2_HOME" ] &&
+    M2_HOME=`cygpath --path --windows "$M2_HOME"`
+  [ -n "$JAVA_HOME" ] &&
+    JAVA_HOME=`cygpath --path --windows "$JAVA_HOME"`
+  [ -n "$CLASSPATH" ] &&
+    CLASSPATH=`cygpath --path --windows "$CLASSPATH"`
+  [ -n "$MAVEN_PROJECTBASEDIR" ] &&
+    MAVEN_PROJECTBASEDIR=`cygpath --path --windows "$MAVEN_PROJECTBASEDIR"`
+fi
+
+# Provide a "standardized" way to retrieve the CLI args that will
+# work with both Windows and non-Windows executions.
+MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $@"
+export MAVEN_CMD_LINE_ARGS
+
+WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain
+
+exec "$JAVACMD" \
+  $MAVEN_OPTS \
+  -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \
+  "-Dmaven.home=${M2_HOME}" "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \
+  ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@"
diff --git a/analytics-server/mvnw.cmd b/analytics-server/mvnw.cmd
new file mode 100644
index 0000000000000000000000000000000000000000..c8d43372c986d97911cdc21bd87e0cbe3d83bdda
--- /dev/null
+++ b/analytics-server/mvnw.cmd
@@ -0,0 +1,182 @@
+@REM ----------------------------------------------------------------------------
+@REM Licensed to the Apache Software Foundation (ASF) under one
+@REM or more contributor license agreements.  See the NOTICE file
+@REM distributed with this work for additional information
+@REM regarding copyright ownership.  The ASF licenses this file
+@REM to you under the Apache License, Version 2.0 (the
+@REM "License"); you may not use this file except in compliance
+@REM with the License.  You may obtain a copy of the License at
+@REM
+@REM    https://www.apache.org/licenses/LICENSE-2.0
+@REM
+@REM Unless required by applicable law or agreed to in writing,
+@REM software distributed under the License is distributed on an
+@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+@REM KIND, either express or implied.  See the License for the
+@REM specific language governing permissions and limitations
+@REM under the License.
+@REM ----------------------------------------------------------------------------
+
+@REM ----------------------------------------------------------------------------
+@REM Maven Start Up Batch script
+@REM
+@REM Required ENV vars:
+@REM JAVA_HOME - location of a JDK home dir
+@REM
+@REM Optional ENV vars
+@REM M2_HOME - location of maven2's installed home dir
+@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands
+@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending
+@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven
+@REM     e.g. to debug Maven itself, use
+@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000
+@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files
+@REM ----------------------------------------------------------------------------
+
+@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on'
+@echo off
+@REM set title of command window
+title %0
+@REM enable echoing by setting MAVEN_BATCH_ECHO to 'on'
+@if "%MAVEN_BATCH_ECHO%" == "on"  echo %MAVEN_BATCH_ECHO%
+
+@REM set %HOME% to equivalent of $HOME
+if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%")
+
+@REM Execute a user defined script before this one
+if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre
+@REM check for pre script, once with legacy .bat ending and once with .cmd ending
+if exist "%HOME%\mavenrc_pre.bat" call "%HOME%\mavenrc_pre.bat"
+if exist "%HOME%\mavenrc_pre.cmd" call "%HOME%\mavenrc_pre.cmd"
+:skipRcPre
+
+@setlocal
+
+set ERROR_CODE=0
+
+@REM To isolate internal variables from possible post scripts, we use another setlocal
+@setlocal
+
+@REM ==== START VALIDATION ====
+if not "%JAVA_HOME%" == "" goto OkJHome
+
+echo.
+echo Error: JAVA_HOME not found in your environment. >&2
+echo Please set the JAVA_HOME variable in your environment to match the >&2
+echo location of your Java installation. >&2
+echo.
+goto error
+
+:OkJHome
+if exist "%JAVA_HOME%\bin\java.exe" goto init
+
+echo.
+echo Error: JAVA_HOME is set to an invalid directory. >&2
+echo JAVA_HOME = "%JAVA_HOME%" >&2
+echo Please set the JAVA_HOME variable in your environment to match the >&2
+echo location of your Java installation. >&2
+echo.
+goto error
+
+@REM ==== END VALIDATION ====
+
+:init
+
+@REM Find the project base dir, i.e. the directory that contains the folder ".mvn".
+@REM Fallback to current working directory if not found.
+
+set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR%
+IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir
+
+set EXEC_DIR=%CD%
+set WDIR=%EXEC_DIR%
+:findBaseDir
+IF EXIST "%WDIR%"\.mvn goto baseDirFound
+cd ..
+IF "%WDIR%"=="%CD%" goto baseDirNotFound
+set WDIR=%CD%
+goto findBaseDir
+
+:baseDirFound
+set MAVEN_PROJECTBASEDIR=%WDIR%
+cd "%EXEC_DIR%"
+goto endDetectBaseDir
+
+:baseDirNotFound
+set MAVEN_PROJECTBASEDIR=%EXEC_DIR%
+cd "%EXEC_DIR%"
+
+:endDetectBaseDir
+
+IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig
+
+@setlocal EnableExtensions EnableDelayedExpansion
+for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a
+@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS%
+
+:endReadAdditionalConfig
+
+SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe"
+set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar"
+set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain
+
+set DOWNLOAD_URL="https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar"
+
+FOR /F "tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO (
+    IF "%%A"=="wrapperUrl" SET DOWNLOAD_URL=%%B
+)
+
+@REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central
+@REM This allows using the maven wrapper in projects that prohibit checking in binary data.
+if exist %WRAPPER_JAR% (
+    if "%MVNW_VERBOSE%" == "true" (
+        echo Found %WRAPPER_JAR%
+    )
+) else (
+    if not "%MVNW_REPOURL%" == "" (
+        SET DOWNLOAD_URL="%MVNW_REPOURL%/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar"
+    )
+    if "%MVNW_VERBOSE%" == "true" (
+        echo Couldn't find %WRAPPER_JAR%, downloading it ...
+        echo Downloading from: %DOWNLOAD_URL%
+    )
+
+    powershell -Command "&{"^
+		"$webclient = new-object System.Net.WebClient;"^
+		"if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^
+		"$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^
+		"}"^
+		"[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%DOWNLOAD_URL%', '%WRAPPER_JAR%')"^
+		"}"
+    if "%MVNW_VERBOSE%" == "true" (
+        echo Finished downloading %WRAPPER_JAR%
+    )
+)
+@REM End of extension
+
+@REM Provide a "standardized" way to retrieve the CLI args that will
+@REM work with both Windows and non-Windows executions.
+set MAVEN_CMD_LINE_ARGS=%*
+
+%MAVEN_JAVA_EXE% %JVM_CONFIG_MAVEN_PROPS% %MAVEN_OPTS% %MAVEN_DEBUG_OPTS% -classpath %WRAPPER_JAR% "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %*
+if ERRORLEVEL 1 goto error
+goto end
+
+:error
+set ERROR_CODE=1
+
+:end
+@endlocal & set ERROR_CODE=%ERROR_CODE%
+
+if not "%MAVEN_SKIP_RC%" == "" goto skipRcPost
+@REM check for post script, once with legacy .bat ending and once with .cmd ending
+if exist "%HOME%\mavenrc_post.bat" call "%HOME%\mavenrc_post.bat"
+if exist "%HOME%\mavenrc_post.cmd" call "%HOME%\mavenrc_post.cmd"
+:skipRcPost
+
+@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on'
+if "%MAVEN_BATCH_PAUSE%" == "on" pause
+
+if "%MAVEN_TERMINATE_CMD%" == "on" exit %ERROR_CODE%
+
+exit /B %ERROR_CODE%
diff --git a/analytics-server/pom.xml b/analytics-server/pom.xml
new file mode 100644
index 0000000000000000000000000000000000000000..da725ccd7f1be95c18b375aaa3a658fbb975d9a0
--- /dev/null
+++ b/analytics-server/pom.xml
@@ -0,0 +1,242 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+
+    <parent>
+        <groupId>org.springframework.boot</groupId>
+        <artifactId>spring-boot-starter-parent</artifactId>
+        <version>2.4.3</version>
+        <relativePath/>
+    </parent>
+
+    <groupId>fr.gouv.tousantic</groupId>
+    <artifactId>analytics-server</artifactId>
+    <version>1.0.0-SNAPSHOT</version>
+    <packaging>jar</packaging>
+
+
+    <properties>
+        <java.version>11</java.version>
+
+        <commons-lang3.version>3.11</commons-lang3.version>
+        <javax.inject.version>1</javax.inject.version>
+        <mapstruct.version>1.4.2.Final</mapstruct.version>
+        <spring-cloud.version>2020.0.1</spring-cloud.version>
+        <spring-kafka.version>2.6.6</spring-kafka.version>
+    </properties>
+
+
+    <dependencyManagement>
+        <dependencies>
+            <dependency>
+                <groupId>org.springframework.cloud</groupId>
+                <artifactId>spring-cloud-dependencies</artifactId>
+                <version>${spring-cloud.version}</version>
+                <type>pom</type>
+                <scope>import</scope>
+            </dependency>
+        </dependencies>
+    </dependencyManagement>
+
+
+    <dependencies>
+
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-actuator</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-web</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-validation</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-data-mongodb</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.cloud</groupId>
+            <artifactId>spring-cloud-starter-bootstrap</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.cloud</groupId>
+            <artifactId>spring-cloud-starter-consul-config</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.cloud</groupId>
+            <artifactId>spring-cloud-starter-vault-config</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.kafka</groupId>
+            <artifactId>spring-kafka</artifactId>
+            <version>${spring-kafka.version}</version>
+        </dependency>
+
+
+        <dependency>
+            <groupId>javax.inject</groupId>
+            <artifactId>javax.inject</artifactId>
+            <version>${javax.inject.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>org.mapstruct</groupId>
+            <artifactId>mapstruct</artifactId>
+            <version>${mapstruct.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.commons</groupId>
+            <artifactId>commons-lang3</artifactId>
+            <version>${commons-lang3.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>io.micrometer</groupId>
+            <artifactId>micrometer-registry-prometheus</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.projectlombok</groupId>
+            <artifactId>lombok</artifactId>
+            <optional>true</optional>
+        </dependency>
+
+
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-test</artifactId>
+            <scope>test</scope>
+            <exclusions>
+                <exclusion>
+                    <groupId>org.junit.vintage</groupId>
+                    <artifactId>junit-vintage-engine</artifactId>
+                </exclusion>
+            </exclusions>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.security</groupId>
+            <artifactId>spring-security-test</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.assertj</groupId>
+            <artifactId>assertj-core</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.awaitility</groupId>
+            <artifactId>awaitility</artifactId>
+            <version>${awaitility.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.kafka</groupId>
+            <artifactId>spring-kafka-test</artifactId>
+            <version>${spring-kafka.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>de.flapdoodle.embed</groupId>
+            <artifactId>de.flapdoodle.embed.mongo</artifactId>
+            <scope>test</scope>
+        </dependency>
+
+    </dependencies>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.springframework.boot</groupId>
+                <artifactId>spring-boot-maven-plugin</artifactId>
+                <executions>
+                    <execution>
+                        <goals>
+                            <goal>build-info</goal>
+                        </goals>
+                    </execution>
+                </executions>
+            </plugin>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-compiler-plugin</artifactId>
+                <version>${maven-compiler-plugin.version}</version>
+                <configuration>
+                    <annotationProcessorPaths>
+                        <path>
+                            <groupId>org.projectlombok</groupId>
+                            <artifactId>lombok</artifactId>
+                            <version>${lombok.version}</version>
+                        </path>
+                        <path>
+                            <groupId>org.mapstruct</groupId>
+                            <artifactId>mapstruct-processor</artifactId>
+                            <version>${mapstruct.version}</version>
+                        </path>
+                    </annotationProcessorPaths>
+                    <compilerArgs>
+                        <compilerArg>-Amapstruct.defaultComponentModel=spring</compilerArg>
+                        <compilerArg>-parameters</compilerArg>
+                    </compilerArgs>
+                </configuration>
+            </plugin>
+            <plugin>
+                <groupId>org.jacoco</groupId>
+                <artifactId>jacoco-maven-plugin</artifactId>
+                <version>0.8.5</version>
+                <configuration>
+                    <output>file</output>
+                    <append>true</append>
+                </configuration>
+                <executions>
+                    <execution>
+                        <id>jacoco-initialize</id>
+                        <phase>initialize</phase>
+                        <goals>
+                            <goal>prepare-agent</goal>
+                        </goals>
+                    </execution>
+                    <execution>
+                        <id>jacoco-site</id>
+                        <phase>verify</phase>
+                        <goals>
+                            <goal>report</goal>
+                        </goals>
+                    </execution>
+                </executions>
+            </plugin>
+
+        </plugins>
+    </build>
+
+
+    <repositories>
+        <repository>
+            <id>gitlab-maven</id>
+            <url>${env.CI_SERVER_URL}/api/v4/projects/${env.CI_PROJECT_ID}/packages/maven</url>
+        </repository>
+    </repositories>
+    <distributionManagement>
+        <repository>
+            <id>gitlab-maven</id>
+            <url>${env.CI_SERVER_URL}/api/v4/projects/${env.CI_PROJECT_ID}/packages/maven</url>
+        </repository>
+        <snapshotRepository>
+            <id>gitlab-maven</id>
+            <url>${env.CI_SERVER_URL}/api/v4/projects/${env.CI_PROJECT_ID}/packages/maven</url>
+        </snapshotRepository>
+    </distributionManagement>
+
+
+    <scm>
+        <connection>scm:git:${gitRepositoryUrl}</connection>
+        <developerConnection>scm:git:${gitRepositoryUrl}</developerConnection>
+        <url>${gitRepositoryUrl}</url>
+        <tag>HEAD</tag>
+    </scm>
+
+</project>
diff --git a/analytics-server/src/main/doc/TAC-analytics.postman_collection.json b/analytics-server/src/main/doc/TAC-analytics.postman_collection.json
new file mode 100644
index 0000000000000000000000000000000000000000..ed1d14ed7a13dd7a51b8702a909d9ac94a619d45
--- /dev/null
+++ b/analytics-server/src/main/doc/TAC-analytics.postman_collection.json
@@ -0,0 +1,48 @@
+{
+	"info": {
+		"_postman_id": "d4f7ab3f-c595-4cab-91a5-75921c944e41",
+		"name": "TAC-analytics",
+		"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
+	},
+	"item": [
+		{
+			"name": "Post - analytics",
+			"request": {
+				"method": "POST",
+				"header": [
+					{
+						"key": "Authorization",
+						"value": "Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMiwianRpIjoiYWJjZGUifQ.G0oQVGvH1m97TTppY9J8ypdHI0saNjVyDt4wBQ1CV3OYQe9eK3xrQjhs6y0a1YpGlpcMTHYyBeRG15Ze-1XDoH0aKbLiQ-t9RddbyrctDG3gKdJkixfczwFIjt2BTJIpmcZAgx2aBbYU_SObFwn2hDn9GnNy2BPpGxHnPVTdzO9MuIcFZURcLvokiYM3tssue7mff4hep90F55hJPg_UC5ggm3bgQ78Ysu6gsImDDZoc1tn1bVEdXxkQ1LywG9U9zylPER2tczKnr8m0rCK6vNVnzsXjM1YKzJmhbR53BeXbTAGcUqQWZwDej3VvlkJxq3McBmMKihlDw0sov_ABKw",
+						"type": "text"
+					}
+				],
+				"body": {
+					"mode": "raw",
+					"raw": "{\n    \"installationUuid\": \"toto\",\n     \"infos\": {\n    \"additionalProp1\": \"string\",\n    \"additionalProp2\": \"string\",\n    \"additionalProp3\": \"string\"\n  },\n  \"events\": [\n    {\n      \"name\": \"string\",\n      \"timestamp\": \"2021-02-10T17:46:54.167Z\",\n      \"description\": \"string\"\n    }\n  ],\n  \"errors\": [\n    {\n      \"name\": \"string\",\n      \"timestamp\": \"2021-02-10T17:46:54.167Z\",\n      \"description\": \"string\"\n    }\n  ]\n\n\n\n}",
+					"options": {
+						"raw": {
+							"language": "json"
+						}
+					}
+				},
+				"url": {
+					"raw": "http://127.0.0.1:8087/api/v4/analytics",
+					"protocol": "http",
+					"host": [
+						"127",
+						"0",
+						"0",
+						"1"
+					],
+					"port": "8087",
+					"path": [
+						"api",
+						"v4",
+						"analytics"
+					]
+				}
+			},
+			"response": []
+		}
+	]
+}
\ No newline at end of file
diff --git a/analytics-server/src/main/doc/openid_analytics.yml b/analytics-server/src/main/doc/openid_analytics.yml
new file mode 100644
index 0000000000000000000000000000000000000000..6aedbb31863db438b66b8dbd4191590fe3825ab0
--- /dev/null
+++ b/analytics-server/src/main/doc/openid_analytics.yml
@@ -0,0 +1,137 @@
+openapi: 3.0.0
+info:
+  description: "#TOUS-ANTI-COVID, Robert analytics Client API"
+  version: 1.0.0
+  title: "#TOUS-ANTI-COVID, Robert analytics"
+  termsOfService: https://gitlab.inria.fr/stemcovid19/wp3-robert-server/analytics-server
+  contact:
+    email: stopcovid@inria.fr
+  license:
+    name: Apache 2.0
+    url: http://www.apache.org/licenses/LICENSE-2.0.html
+tags: [ ]
+paths:
+  /analytics:
+    post:
+      tags: [ "analytics" ]
+      summary: Create analytics
+      description: |-
+        Request the creation of analytics. Nothing is returned
+
+      operationId: createAnalytics
+      requestBody:
+        content:
+          application/json:
+            schema:
+              $ref: "#/components/schemas/AnalyticsRequest"
+
+      responses:
+        "200":
+          description: successful operation
+        "400":
+          description: Bad request, something is incorrect in the request
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/ErrorResponse"
+        "401":
+          description: Unauthorized request, bad, missing or invalid token
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/ErrorResponse"
+        "500":
+          description: An unexpected error occurs
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/ErrorResponse"
+      security:
+        - bearerAuth: [ ]
+
+components:
+  securitySchemes:
+    bearerAuth:
+      type: http
+      scheme: bearer
+      bearerFormat: JWT
+
+  requestBodies:
+    AnalyticsRequest:
+      content:
+        application/json:
+          schema:
+            $ref: "#/components/schemas/AnalyticsRequest"
+
+  schemas:
+    AnalyticsRequest:
+      type: object
+      properties:
+        installationUuid:
+          type: string
+          maxLength: 64
+          description: the installation unique identifier. This identifier is generated by the mobile application during its installation.
+        infos:
+          $ref: "#/components/schemas/Information"
+          description:
+            The list of infos.
+        events:
+          type: array
+          items:
+            $ref: "#/components/schemas/TimestampedEvent"
+          description:
+            The events list of the analytics.
+        errors:
+          type: array
+          items:
+            $ref: "#/components/schemas/TimestampedEvent"
+          description:
+            The errors list of the analytics.
+      required:
+        - installationUuid
+
+
+    Information:
+      type: object
+      maxProperties: 20
+      additionalProperties:
+        type: string
+        maxLength: 512
+
+    TimestampedEvent:
+      type: object
+      properties:
+        name:
+          type: string
+          maxLength: 64
+          description: The event name.
+        timestamp:
+          type: string
+          format: date-time
+          maxLength: 64
+          description: The BasicInfo timestamp. Date-time notation as defined by RFC 3339, section 5.6, for example, 2017-07-21T17:32:28Z
+        description:
+          type: string
+          maxLength: 512
+          description: the basic info description
+      required:
+        - name
+        - timestamp
+
+    ErrorResponse:
+      type: object
+      properties:
+        message:
+          type: string
+          description: the error message
+        timestamp:
+          type: string
+          format: date-time
+          description: the error timestamp
+      required:
+        - message
+        - timestamp
+
+
+
+
diff --git a/analytics-server/src/main/java/fr/gouv/tac/analytics/server/AnalyticsServerApplication.java b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/AnalyticsServerApplication.java
new file mode 100644
index 0000000000000000000000000000000000000000..c1e539ab659df3322f2b96d13b5cce1ca8afdb01
--- /dev/null
+++ b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/AnalyticsServerApplication.java
@@ -0,0 +1,13 @@
+package fr.gouv.tac.analytics.server;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+
+@SpringBootApplication
+public class AnalyticsServerApplication {
+
+    public static void main(final String[] args) {
+        SpringApplication.run(AnalyticsServerApplication.class, args);
+    }
+}
diff --git a/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/kafka/KafkaConfiguration.java b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/kafka/KafkaConfiguration.java
new file mode 100644
index 0000000000000000000000000000000000000000..63b156389756573940824ab99133bc0f6fd703ab
--- /dev/null
+++ b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/kafka/KafkaConfiguration.java
@@ -0,0 +1,38 @@
+package fr.gouv.tac.analytics.server.config.kafka;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import fr.gouv.tac.analytics.server.model.kafka.Analytics;
+import org.apache.kafka.clients.producer.ProducerConfig;
+import org.apache.kafka.common.serialization.StringSerializer;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.kafka.core.DefaultKafkaProducerFactory;
+import org.springframework.kafka.core.KafkaTemplate;
+import org.springframework.kafka.core.ProducerFactory;
+import org.springframework.kafka.support.serializer.JsonSerializer;
+
+import java.util.Map;
+
+@Configuration
+public class KafkaConfiguration {
+
+    @Value(value = "${spring.kafka.bootstrap-servers}")
+    private String bootstrapAddress;
+
+    @Value(value = "${spring.kafka.template.default-topic}")
+    private String defaultTopic;
+
+    @Bean
+    public KafkaTemplate<String, Analytics> analyticsKafkaTemplate(final ObjectMapper objectMapper) {
+        final Map<String, Object> props = Map.of(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapAddress);
+
+        final ProducerFactory<String, Analytics> producerFactory = new DefaultKafkaProducerFactory<>(props,
+                new StringSerializer(),
+                new JsonSerializer<Analytics>(objectMapper));
+
+        final KafkaTemplate<String, Analytics> analyticsKafkaTemplate = new KafkaTemplate<>(producerFactory);
+        analyticsKafkaTemplate.setDefaultTopic(defaultTopic);
+        return analyticsKafkaTemplate;
+    }
+}
diff --git a/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/mongodb/MongoConfiguration.java b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/mongodb/MongoConfiguration.java
new file mode 100644
index 0000000000000000000000000000000000000000..bdea58bcae753a9078b62073df8c3f03ade11eec
--- /dev/null
+++ b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/mongodb/MongoConfiguration.java
@@ -0,0 +1,32 @@
+package fr.gouv.tac.analytics.server.config.mongodb;
+
+import fr.gouv.tac.analytics.server.config.mongodb.converters.DateToZonedDateTimeConverter;
+import fr.gouv.tac.analytics.server.config.mongodb.converters.ZonedDateTimeToDateConverter;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.data.mongodb.core.convert.MongoCustomConversions;
+import org.springframework.data.mongodb.core.mapping.event.ValidatingMongoEventListener;
+import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;
+
+import java.util.Arrays;
+
+
+@Configuration
+public class MongoConfiguration {
+
+    @Bean
+    public MongoCustomConversions customConversions() {
+        return new MongoCustomConversions(
+                Arrays.asList(
+                        new DateToZonedDateTimeConverter(),
+                        new ZonedDateTimeToDateConverter())
+        );
+    }
+
+    @Bean
+    public ValidatingMongoEventListener validatingMongoEventListener(final LocalValidatorFactoryBean localValidatorFactoryBean) {
+        return new ValidatingMongoEventListener(localValidatorFactoryBean);
+    }
+
+
+}
diff --git a/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/mongodb/MongoIndexCreationListener.java b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/mongodb/MongoIndexCreationListener.java
new file mode 100644
index 0000000000000000000000000000000000000000..d0705d0c8baa4261b7dc987a83498aefa88e7f2c
--- /dev/null
+++ b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/mongodb/MongoIndexCreationListener.java
@@ -0,0 +1,68 @@
+package fr.gouv.tac.analytics.server.config.mongodb;
+
+import fr.gouv.tac.analytics.server.model.mongo.TokenIdentifier;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.context.event.ApplicationReadyEvent;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.event.EventListener;
+import org.springframework.data.domain.Sort;
+import org.springframework.data.mongodb.core.MongoTemplate;
+import org.springframework.data.mongodb.core.index.Index;
+import org.springframework.data.mongodb.core.index.IndexInfo;
+import org.springframework.util.StopWatch;
+
+import java.time.Duration;
+import java.util.Optional;
+
+@Slf4j
+@RequiredArgsConstructor
+@Configuration
+public class MongoIndexCreationListener {
+
+    private static final String TOKEN_IDENTIFIER_IDX_NAME = "TokenIdentifierIdentifierUk";
+    private static final String TOKEN_IDENTIFIER_EXPIRATION_DATE_IDX_NAME = "TokenIdentifierExpirationDateIdx";
+
+    private final MongoTemplate mongoTemplate;
+
+    @EventListener(ApplicationReadyEvent.class)
+    public void ensureIndexesCreationAfterStartup() {
+
+        log.info("Mongo ensureIndexesCreationAfterStartup init");
+        final StopWatch stopWatch = new StopWatch();
+        stopWatch.start();
+
+        ensureTokenIdentifierCreationDateIndex();
+        ensureTokenIdentifierIdentifierUniqueIndex();
+
+
+        stopWatch.stop();
+        log.info("Mongo ensureIndexesCreationAfterStartup takes: {} ms", stopWatch.getTotalTimeMillis());
+    }
+
+
+    public void ensureTokenIdentifierIdentifierUniqueIndex() {
+        mongoTemplate.indexOps(TokenIdentifier.class).ensureIndex(new Index().named(TOKEN_IDENTIFIER_IDX_NAME).on(TokenIdentifier.IDENTIFIER_FIELD_NAME, Sort.Direction.ASC).unique());
+    }
+
+    public void ensureTokenIdentifierCreationDateIndex() {
+        ensureExpirationIndex(TokenIdentifier.class, TOKEN_IDENTIFIER_EXPIRATION_DATE_IDX_NAME, TokenIdentifier.EXPIRATION_DATE_FIELD_NAME, Duration.ofSeconds(0));
+    }
+
+    private void ensureExpirationIndex(final Class entity, final String indexName, final String field, final Duration duration) {
+        final Optional<IndexInfo> alreadyExistingValidIndex = mongoTemplate.indexOps(entity)
+                .getIndexInfo()
+                .stream()
+                .filter(indexInfo -> indexName.contentEquals(indexInfo.getName()) && indexInfo.getExpireAfter().isPresent() && indexInfo.getExpireAfter().get().equals(duration))
+                .findFirst();
+
+        if (alreadyExistingValidIndex.isEmpty()) {
+            log.info("No suitable index found with name {}, drop it and recreate it", indexName);
+            mongoTemplate.indexOps(entity).dropIndex(indexName);
+            mongoTemplate.indexOps(entity).ensureIndex(
+                    new Index().named(indexName).on(field, Sort.Direction.ASC).expire(duration).background()
+            );
+            log.info("Index {}, created", indexName);
+        }
+    }
+}
diff --git a/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/mongodb/converters/DateToZonedDateTimeConverter.java b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/mongodb/converters/DateToZonedDateTimeConverter.java
new file mode 100644
index 0000000000000000000000000000000000000000..806777054aec0affba2a790911e299552f638728
--- /dev/null
+++ b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/mongodb/converters/DateToZonedDateTimeConverter.java
@@ -0,0 +1,19 @@
+package fr.gouv.tac.analytics.server.config.mongodb.converters;
+
+import org.springframework.core.convert.converter.Converter;
+import org.springframework.data.convert.ReadingConverter;
+
+import java.time.ZoneOffset;
+import java.time.ZonedDateTime;
+import java.util.Date;
+
+import static java.time.ZonedDateTime.ofInstant;
+
+@ReadingConverter
+public class DateToZonedDateTimeConverter implements Converter<Date, ZonedDateTime> {
+
+    @Override
+    public ZonedDateTime convert(final Date source) {
+        return ofInstant(source.toInstant(), ZoneOffset.UTC);
+    }
+}
diff --git a/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/mongodb/converters/ZonedDateTimeToDateConverter.java b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/mongodb/converters/ZonedDateTimeToDateConverter.java
new file mode 100644
index 0000000000000000000000000000000000000000..95ff012291f8a2ed2098a0331088e95192336c9e
--- /dev/null
+++ b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/mongodb/converters/ZonedDateTimeToDateConverter.java
@@ -0,0 +1,16 @@
+package fr.gouv.tac.analytics.server.config.mongodb.converters;
+
+import org.springframework.core.convert.converter.Converter;
+import org.springframework.data.convert.WritingConverter;
+
+import java.time.ZonedDateTime;
+import java.util.Date;
+
+@WritingConverter
+public class ZonedDateTimeToDateConverter implements Converter<ZonedDateTime, Date> {
+
+    @Override
+    public Date convert(final ZonedDateTime source) {
+        return Date.from(source.toInstant());
+    }
+}
diff --git a/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/mongodb/datetimeprovider/ZoneDateTimeTimeProvider.java b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/mongodb/datetimeprovider/ZoneDateTimeTimeProvider.java
new file mode 100644
index 0000000000000000000000000000000000000000..6b57fefdc3bc41341bfcdefb2164f3378444a906
--- /dev/null
+++ b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/mongodb/datetimeprovider/ZoneDateTimeTimeProvider.java
@@ -0,0 +1,14 @@
+package fr.gouv.tac.analytics.server.config.mongodb.datetimeprovider;
+
+import org.springframework.data.auditing.DateTimeProvider;
+
+import java.time.ZonedDateTime;
+import java.time.temporal.TemporalAccessor;
+import java.util.Optional;
+
+public class ZoneDateTimeTimeProvider implements DateTimeProvider {
+    @Override
+    public Optional<TemporalAccessor> getNow() {
+        return Optional.of(ZonedDateTime.now());
+    }
+}
diff --git a/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/security/WebSecurityConfiguration.java b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/security/WebSecurityConfiguration.java
new file mode 100644
index 0000000000000000000000000000000000000000..c11cd8826f6fbdc3428b3ec4906549b36c0b1333
--- /dev/null
+++ b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/security/WebSecurityConfiguration.java
@@ -0,0 +1,81 @@
+package fr.gouv.tac.analytics.server.config.security;
+
+import java.nio.charset.StandardCharsets;
+import java.security.KeyFactory;
+import java.security.NoSuchAlgorithmException;
+import java.security.interfaces.RSAPublicKey;
+import java.security.spec.InvalidKeySpecException;
+import java.security.spec.X509EncodedKeySpec;
+import java.util.Base64;
+
+import javax.annotation.PostConstruct;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
+import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
+import org.springframework.security.config.http.SessionCreationPolicy;
+import org.springframework.security.oauth2.jwt.JwtDecoder;
+import org.springframework.security.oauth2.jwt.JwtTimestampValidator;
+import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
+import org.springframework.security.web.AuthenticationEntryPoint;
+import org.springframework.web.servlet.HandlerExceptionResolver;
+
+import fr.gouv.tac.analytics.server.config.security.oauth2tokenvalidator.DelegatingOAuth2JwtTokenValidator;
+import fr.gouv.tac.analytics.server.config.security.oauth2tokenvalidator.ExpirationTokenPresenceOAuth2TokenValidator;
+import fr.gouv.tac.analytics.server.config.security.oauth2tokenvalidator.JtiCanOnlyBeUsedOnceOAuth2TokenValidator;
+import fr.gouv.tac.analytics.server.config.security.oauth2tokenvalidator.JtiPresenceOAuth2TokenValidator;
+
+@EnableWebSecurity
+public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {
+
+    private static final String RSA_ALGORITHM = "RSA";
+
+    @Autowired
+    private HandlerExceptionResolver handlerExceptionResolver;
+
+    @Value("${analyticsserver.robert_jwt_analyticspublickey}")
+    private String robertJwtPublicKeyString;
+
+    private RSAPublicKey robertRSAPublicKey;
+
+    @PostConstruct
+    public void init() throws NoSuchAlgorithmException, InvalidKeySpecException {
+        final byte[] robertJwtPublicKeyBytes = Base64.getDecoder().decode(robertJwtPublicKeyString.getBytes(StandardCharsets.UTF_8));
+        final X509EncodedKeySpec X509publicKey = new X509EncodedKeySpec(robertJwtPublicKeyBytes, RSA_ALGORITHM);
+        final KeyFactory kf = KeyFactory.getInstance(RSA_ALGORITHM);
+        robertRSAPublicKey = (RSAPublicKey) kf.generatePublic(X509publicKey);
+    }
+
+    @Override
+    protected void configure(final HttpSecurity httpSecurity) throws Exception {
+        httpSecurity
+                .httpBasic().disable()
+                .formLogin(AbstractHttpConfigurer::disable)
+                .csrf(AbstractHttpConfigurer::disable)
+                .authorizeRequests()
+                .antMatchers("/actuator/**").permitAll()
+                .anyRequest().authenticated()
+                .and().oauth2ResourceServer(oauth2 -> oauth2.jwt().and().authenticationEntryPoint(authenticationEntryPoint()))
+                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
+    }
+
+    @Bean
+    public AuthenticationEntryPoint authenticationEntryPoint() {
+        return (request, response, authException) -> handlerExceptionResolver.resolveException(request, response, null, authException);
+    }
+
+    @Bean
+    public JwtDecoder jwtDecoder(final JtiPresenceOAuth2TokenValidator jtiPresenceOAuth2TokenValidator,
+                                 final JtiCanOnlyBeUsedOnceOAuth2TokenValidator jtiCanOnlyBeUsedOnceOAuth2TokenValidator,
+                                 final ExpirationTokenPresenceOAuth2TokenValidator expirationTokenPresenceOAuth2TokenValidator) {
+        final NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withPublicKey(robertRSAPublicKey).build();
+        final DelegatingOAuth2JwtTokenValidator delegatingTokenValidator = new DelegatingOAuth2JwtTokenValidator(expirationTokenPresenceOAuth2TokenValidator, new JwtTimestampValidator(), jtiPresenceOAuth2TokenValidator, jtiCanOnlyBeUsedOnceOAuth2TokenValidator);
+        jwtDecoder.setJwtValidator(delegatingTokenValidator);
+        return jwtDecoder;
+    }
+
+}
diff --git a/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/security/oauth2tokenvalidator/DelegatingOAuth2JwtTokenValidator.java b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/security/oauth2tokenvalidator/DelegatingOAuth2JwtTokenValidator.java
new file mode 100644
index 0000000000000000000000000000000000000000..729a54a99bfdea28ff35b09aa453b16c95d5f729
--- /dev/null
+++ b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/security/oauth2tokenvalidator/DelegatingOAuth2JwtTokenValidator.java
@@ -0,0 +1,36 @@
+package fr.gouv.tac.analytics.server.config.security.oauth2tokenvalidator;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Iterator;
+import java.util.List;
+
+import org.springframework.security.oauth2.core.OAuth2Error;
+import org.springframework.security.oauth2.core.OAuth2TokenValidator;
+import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
+import org.springframework.security.oauth2.jwt.Jwt;
+
+public class DelegatingOAuth2JwtTokenValidator implements OAuth2TokenValidator<Jwt> {
+
+    private final List<OAuth2TokenValidator<Jwt>> oAuth2TokenValidators;
+
+    @SafeVarargs
+    public DelegatingOAuth2JwtTokenValidator(final OAuth2TokenValidator<Jwt>... tokenValidators) {
+        oAuth2TokenValidators = Arrays.asList(tokenValidators);
+    }
+
+    @Override
+    public OAuth2TokenValidatorResult validate(final Jwt token) {
+        final Collection<OAuth2Error> errors = new ArrayList<>();
+
+        final Iterator<OAuth2TokenValidator<Jwt>> oAuth2TokenValidatorIterator = oAuth2TokenValidators.iterator();
+
+        while (oAuth2TokenValidatorIterator.hasNext() && errors.isEmpty()) {
+            final OAuth2TokenValidator<Jwt> oAuth2TokenValidator = oAuth2TokenValidatorIterator.next();
+            errors.addAll(oAuth2TokenValidator.validate(token).getErrors());
+        }
+
+        return OAuth2TokenValidatorResult.failure(errors);
+    }
+}
diff --git a/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/security/oauth2tokenvalidator/ExpirationTokenPresenceOAuth2TokenValidator.java b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/security/oauth2tokenvalidator/ExpirationTokenPresenceOAuth2TokenValidator.java
new file mode 100644
index 0000000000000000000000000000000000000000..a3894f4f0d192870419e2e1363d0a16790db51b2
--- /dev/null
+++ b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/security/oauth2tokenvalidator/ExpirationTokenPresenceOAuth2TokenValidator.java
@@ -0,0 +1,28 @@
+package fr.gouv.tac.analytics.server.config.security.oauth2tokenvalidator;
+
+import java.util.Objects;
+
+import org.springframework.security.oauth2.core.OAuth2Error;
+import org.springframework.security.oauth2.core.OAuth2TokenValidator;
+import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.stereotype.Component;
+
+@Component
+public class ExpirationTokenPresenceOAuth2TokenValidator implements OAuth2TokenValidator<Jwt> {
+
+    public static final String ERR_MESSAGE = "The token expiration (exp) is missing";
+    static final OAuth2Error EXPIRATION_NOT_FOUND_OAUTH2ERROR = new OAuth2Error("invalid_token", ERR_MESSAGE, null);
+
+    private static final OAuth2TokenValidatorResult FAILURE_RESULT = OAuth2TokenValidatorResult.failure(EXPIRATION_NOT_FOUND_OAUTH2ERROR);
+    private static final OAuth2TokenValidatorResult SUCCESS_RESULT = OAuth2TokenValidatorResult.success();
+
+
+    @Override
+    public OAuth2TokenValidatorResult validate(final Jwt jwt) {
+        if (Objects.isNull(jwt.getExpiresAt())) {
+            return FAILURE_RESULT;
+        }
+        return SUCCESS_RESULT;
+    }
+}
diff --git a/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/security/oauth2tokenvalidator/JtiCanOnlyBeUsedOnceOAuth2TokenValidator.java b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/security/oauth2tokenvalidator/JtiCanOnlyBeUsedOnceOAuth2TokenValidator.java
new file mode 100644
index 0000000000000000000000000000000000000000..f414eb3a1ca5ad36b649d2fcae097fdc9e0fbe6b
--- /dev/null
+++ b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/security/oauth2tokenvalidator/JtiCanOnlyBeUsedOnceOAuth2TokenValidator.java
@@ -0,0 +1,44 @@
+package fr.gouv.tac.analytics.server.config.security.oauth2tokenvalidator;
+
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+
+import javax.inject.Inject;
+
+import org.springframework.security.oauth2.core.OAuth2Error;
+import org.springframework.security.oauth2.core.OAuth2TokenValidator;
+import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.stereotype.Component;
+
+import fr.gouv.tac.analytics.server.service.TokenIdentifierService;
+import lombok.RequiredArgsConstructor;
+
+@Component
+@RequiredArgsConstructor(onConstructor = @__(@Inject))
+public class JtiCanOnlyBeUsedOnceOAuth2TokenValidator implements OAuth2TokenValidator<Jwt> {
+
+    public static final String ERR_MESSAGE = "The token identifier (jti) can only be used once";
+    static final OAuth2Error JTI_USED_MORE_THAN_ONCE_OAUTH2ERROR = new OAuth2Error("invalid_token", ERR_MESSAGE, null);
+
+    private static final ZoneId UTC_ZONEID = ZoneId.of("UTC");
+
+    private static final OAuth2TokenValidatorResult FAILURE_RESULT = OAuth2TokenValidatorResult.failure(JTI_USED_MORE_THAN_ONCE_OAUTH2ERROR);
+    private static final OAuth2TokenValidatorResult SUCCESS_RESULT = OAuth2TokenValidatorResult.success();
+
+    private final TokenIdentifierService tokenIdentifierService;
+
+    @Override
+    public OAuth2TokenValidatorResult validate(final Jwt jwt) {
+
+        final String jti = jwt.getId();
+
+        if (!tokenIdentifierService.tokenIdentifierExist(jti)) {
+            final ZonedDateTime expirationDate = jwt.getExpiresAt().atZone(UTC_ZONEID);
+            tokenIdentifierService.save(jti, expirationDate);
+            return SUCCESS_RESULT;
+        }
+
+        return FAILURE_RESULT;
+    }
+}
diff --git a/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/security/oauth2tokenvalidator/JtiPresenceOAuth2TokenValidator.java b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/security/oauth2tokenvalidator/JtiPresenceOAuth2TokenValidator.java
new file mode 100644
index 0000000000000000000000000000000000000000..3210f9fc2502f500bc3d375fff1a244f7cf2d1d1
--- /dev/null
+++ b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/security/oauth2tokenvalidator/JtiPresenceOAuth2TokenValidator.java
@@ -0,0 +1,28 @@
+package fr.gouv.tac.analytics.server.config.security.oauth2tokenvalidator;
+
+import org.springframework.security.oauth2.core.OAuth2Error;
+import org.springframework.security.oauth2.core.OAuth2TokenValidator;
+import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.stereotype.Component;
+
+import org.apache.commons.lang3.StringUtils;
+
+@Component
+public class JtiPresenceOAuth2TokenValidator implements OAuth2TokenValidator<Jwt> {
+
+    public static final String ERR_MESSAGE = "The token identifier (jti) is missing";
+    static final OAuth2Error JTI_NOT_FOUND_OAUTH2ERROR = new OAuth2Error("invalid_token", ERR_MESSAGE, null);
+
+    private static final OAuth2TokenValidatorResult FAILURE_RESULT = OAuth2TokenValidatorResult.failure(JTI_NOT_FOUND_OAUTH2ERROR);
+    private static final OAuth2TokenValidatorResult SUCCESS_RESULT = OAuth2TokenValidatorResult.success();
+
+
+    @Override
+    public OAuth2TokenValidatorResult validate(final Jwt jwt) {
+        if (StringUtils.isEmpty(jwt.getId())) {
+            return FAILURE_RESULT;
+        }
+        return SUCCESS_RESULT;
+    }
+}
diff --git a/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/validation/ValidationConfiguration.java b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/validation/ValidationConfiguration.java
new file mode 100644
index 0000000000000000000000000000000000000000..1cc0b0aad862b8df23d1cc353d0aee968e372258
--- /dev/null
+++ b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/validation/ValidationConfiguration.java
@@ -0,0 +1,14 @@
+package fr.gouv.tac.analytics.server.config.validation;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;
+
+@Configuration
+public class ValidationConfiguration {
+
+    @Bean
+    public LocalValidatorFactoryBean validator() {
+        return new LocalValidatorFactoryBean();
+    }
+}
diff --git a/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/validation/ValidationParameters.java b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/validation/ValidationParameters.java
new file mode 100644
index 0000000000000000000000000000000000000000..a899fe91b96015bad28662febea9da2b00fdc5fc
--- /dev/null
+++ b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/validation/ValidationParameters.java
@@ -0,0 +1,69 @@
+package fr.gouv.tac.analytics.server.config.validation;
+
+import fr.gouv.tac.analytics.server.config.validation.validator.TimestampedEventCollectionType;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+import org.springframework.validation.annotation.Validated;
+
+import javax.validation.Valid;
+import javax.validation.constraints.NotNull;
+
+@Validated
+@AllArgsConstructor
+@NoArgsConstructor
+@Data
+@Builder
+@Component
+@ConfigurationProperties(prefix = "analyticsserver.validation.analytics")
+public class ValidationParameters {
+
+    @NotNull
+    @Valid
+    private InfoValidationParameters information;
+
+    @NotNull
+    @Valid
+    private TimestampedEventValidationParameters event;
+
+    @NotNull
+    @Valid
+    private TimestampedEventValidationParameters error;
+
+    public TimestampedEventValidationParameters getParameters(final TimestampedEventCollectionType timestampedEventCollectionType) {
+
+        switch (timestampedEventCollectionType) {
+            case EVENT:
+                return event;
+            case ERROR:
+                return error;
+            default:
+                throw new UnsupportedOperationException("This type is not supported : " + timestampedEventCollectionType.toString());
+        }
+    }
+
+
+    @AllArgsConstructor
+    @NoArgsConstructor
+    @Data
+    @Builder
+    public static class InfoValidationParameters {
+        private int maxInfoAllowed;
+        private int maxInfoKeyLength;
+        private int maxInfoValueLength;
+    }
+
+    @AllArgsConstructor
+    @NoArgsConstructor
+    @Data
+    @Builder
+    public static class TimestampedEventValidationParameters {
+        private int maxElementAllowed;
+        private int maxNameLength;
+        private int maxDescriptionLength;
+    }
+
+}
diff --git a/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/validation/validator/AnalyticsVoInfoSize.java b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/validation/validator/AnalyticsVoInfoSize.java
new file mode 100644
index 0000000000000000000000000000000000000000..adc2c0ebec92f626f2b3e9929d9fe31b2d56d066
--- /dev/null
+++ b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/validation/validator/AnalyticsVoInfoSize.java
@@ -0,0 +1,22 @@
+package fr.gouv.tac.analytics.server.config.validation.validator;
+
+import javax.validation.Constraint;
+import javax.validation.Payload;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+@Target({ElementType.METHOD, ElementType.FIELD})
+@Retention(RetentionPolicy.RUNTIME)
+@Constraint(validatedBy = AnalyticsVoInfoSizeValidator.class)
+public @interface AnalyticsVoInfoSize {
+
+    String message() default "Too many info";
+
+    Class<?>[] groups() default {};
+
+    Class<? extends Payload>[] payload() default {
+
+    };
+}
diff --git a/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/validation/validator/AnalyticsVoInfoSizeValidator.java b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/validation/validator/AnalyticsVoInfoSizeValidator.java
new file mode 100644
index 0000000000000000000000000000000000000000..60e0cbbb5b71a1fb5e86673b794998e4e1e71705
--- /dev/null
+++ b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/validation/validator/AnalyticsVoInfoSizeValidator.java
@@ -0,0 +1,78 @@
+package fr.gouv.tac.analytics.server.config.validation.validator;
+
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+
+import javax.inject.Inject;
+import javax.validation.ConstraintValidator;
+import javax.validation.ConstraintValidatorContext;
+
+import fr.gouv.tac.analytics.server.config.validation.ValidationParameters;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+@RequiredArgsConstructor(onConstructor = @__(@Inject))
+public class AnalyticsVoInfoSizeValidator implements ConstraintValidator<AnalyticsVoInfoSize, Map<String, String>> {
+
+    public static final String TOO_MANY_INFO_ERROR_MESSAGE = "Too many info, %d found, whereas the maximum allowed is %d";
+    public static final String KEY_TOO_LONG_ERROR_MESSAGE = "Key with more than %d characters is not allowed, found %d characters";
+    public static final String VALUE_TOO_LONG_ERROR_MESSAGE = "Parameter value with more than %d characters is not allowed, found %d characters";
+
+    private final ValidationParameters validationParameters;
+
+    @Override
+    public boolean isValid(final Map<String, String> value, final ConstraintValidatorContext context) {
+        if (Objects.isNull(value)) {
+            return true;
+        }
+        return isValidMapSize(value, context) && areValidKeySizes(value, context) && areValidValueSizes(value, context);
+    }
+
+
+    private boolean isValidMapSize(final Map<String, String> value, final ConstraintValidatorContext context) {
+        final int maxInfoAllowed = validationParameters.getInformation().getMaxInfoAllowed();
+        if (value.size() > maxInfoAllowed) {
+            contextConfigurer(context, TOO_MANY_INFO_ERROR_MESSAGE, value.size(), maxInfoAllowed);
+            return false;
+        }
+        return true;
+    }
+
+    private boolean areValidKeySizes(final Map<String, String> value, final ConstraintValidatorContext context) {
+        final int maxInfoKeyLength = validationParameters.getInformation().getMaxInfoKeyLength();
+        final Optional<String> firstRejectedKey = value.entrySet().stream()
+                .map(Map.Entry::getKey)
+                .filter(s -> s.length() > maxInfoKeyLength)
+                .findFirst();
+
+        if (firstRejectedKey.isPresent()) {
+            contextConfigurer(context, KEY_TOO_LONG_ERROR_MESSAGE, maxInfoKeyLength, firstRejectedKey.get().length());
+            return false;
+        }
+
+        return true;
+    }
+
+    private boolean areValidValueSizes(final Map<String, String> value, final ConstraintValidatorContext context) {
+        final int maxInfoValueLength = validationParameters.getInformation().getMaxInfoValueLength();
+        final Optional<String> firstRejectedValue = value.entrySet().stream()
+                .map(Map.Entry::getValue)
+                .filter(s -> s.length() > maxInfoValueLength)
+                .findFirst();
+
+        if (firstRejectedValue.isPresent()) {
+            contextConfigurer(context, VALUE_TOO_LONG_ERROR_MESSAGE, maxInfoValueLength, firstRejectedValue.get().length());
+            return false;
+        }
+
+        return true;
+    }
+
+    private void contextConfigurer(final ConstraintValidatorContext context, final String messageTemplate, final Object... messageParameters) {
+        final String errorMessage = String.format(messageTemplate, messageParameters);
+        context.disableDefaultConstraintViolation();
+        context.buildConstraintViolationWithTemplate(errorMessage).addConstraintViolation();
+    }
+}
diff --git a/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/validation/validator/TimestampedEventCollection.java b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/validation/validator/TimestampedEventCollection.java
new file mode 100644
index 0000000000000000000000000000000000000000..caa5e28a31354d0452d36fd9322326124e96ddc5
--- /dev/null
+++ b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/validation/validator/TimestampedEventCollection.java
@@ -0,0 +1,25 @@
+package fr.gouv.tac.analytics.server.config.validation.validator;
+
+import javax.validation.Constraint;
+import javax.validation.Payload;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+@Target({ElementType.METHOD, ElementType.FIELD})
+@Retention(RetentionPolicy.RUNTIME)
+@Constraint(validatedBy = TimestampedEventCollectionValidator.class)
+public @interface TimestampedEventCollection {
+
+    String message() default "Error validating";
+
+    Class<?>[] groups() default {};
+
+    Class<? extends Payload>[] payload() default {
+
+    };
+
+    TimestampedEventCollectionType type();
+
+}
diff --git a/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/validation/validator/TimestampedEventCollectionType.java b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/validation/validator/TimestampedEventCollectionType.java
new file mode 100644
index 0000000000000000000000000000000000000000..35b46246ca2ed2b59852211bf4609284637b7a24
--- /dev/null
+++ b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/validation/validator/TimestampedEventCollectionType.java
@@ -0,0 +1,6 @@
+package fr.gouv.tac.analytics.server.config.validation.validator;
+
+public enum TimestampedEventCollectionType {
+    EVENT,
+    ERROR
+}
diff --git a/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/validation/validator/TimestampedEventCollectionValidator.java b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/validation/validator/TimestampedEventCollectionValidator.java
new file mode 100644
index 0000000000000000000000000000000000000000..202bbb9629ed9ec525b57c51ca97b0cea3543a7d
--- /dev/null
+++ b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/config/validation/validator/TimestampedEventCollectionValidator.java
@@ -0,0 +1,90 @@
+package fr.gouv.tac.analytics.server.config.validation.validator;
+
+import java.util.Collection;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.function.Function;
+
+import javax.inject.Inject;
+import javax.validation.ConstraintValidator;
+import javax.validation.ConstraintValidatorContext;
+
+import org.springframework.util.CollectionUtils;
+
+import fr.gouv.tac.analytics.server.config.validation.ValidationParameters;
+import fr.gouv.tac.analytics.server.controller.vo.TimestampedEventVo;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+@RequiredArgsConstructor(onConstructor = @__(@Inject))
+public class TimestampedEventCollectionValidator implements ConstraintValidator<TimestampedEventCollection, Collection<TimestampedEventVo>> {
+
+    public static final String TOO_MANY_ELEMENTS_ERROR_MESSAGE = "Too many %s, %d found, whereas the maximum allowed is %d";
+    public static final String NAME_TOO_LONG_ERROR_MESSAGE = "For %s, name with more than %d characters is not allowed, found %d characters";
+    public static final String DESCRIPTION_TOO_LONG_ERROR_MESSAGE = "For %s, description with more than %d characters is not allowed, found %d characters";
+
+    private final ValidationParameters validationParameters;
+
+    private TimestampedEventCollectionType timestampedEventCollectionType;
+    private ValidationParameters.TimestampedEventValidationParameters parameters;
+
+    @Override
+    public void initialize(final TimestampedEventCollection timestampedEventCollection) {
+        timestampedEventCollectionType = timestampedEventCollection.type();
+        parameters = validationParameters.getParameters(timestampedEventCollectionType);
+    }
+
+    @Override
+    public boolean isValid(final Collection<TimestampedEventVo> value, final ConstraintValidatorContext context) {
+        if (CollectionUtils.isEmpty(value)) {
+            return true;
+        }
+        return isValidCollectionSize(value, context) && areNameValidSizes(value, context) && areDescriptionValidSizes(value, context);
+    }
+
+    private boolean isValidCollectionSize(final Collection<TimestampedEventVo> value, final ConstraintValidatorContext context) {
+        if (value.size() > parameters.getMaxElementAllowed()) {
+            contextConfigurer(context, TOO_MANY_ELEMENTS_ERROR_MESSAGE, timestampedEventCollectionType, value.size(), parameters.getMaxElementAllowed());
+            return false;
+        }
+        return true;
+    }
+
+    private boolean areNameValidSizes(final Collection<TimestampedEventVo> value, final ConstraintValidatorContext context) {
+        final int maxNameLength = parameters.getMaxNameLength();
+
+        final Optional<String> firstRejectedName = getFirstAttributeWithInvalidLength(value, TimestampedEventVo::getName, maxNameLength);
+
+        if (firstRejectedName.isPresent()) {
+            contextConfigurer(context, NAME_TOO_LONG_ERROR_MESSAGE, timestampedEventCollectionType, maxNameLength, firstRejectedName.get().length());
+            return false;
+        }
+        return true;
+    }
+
+    private boolean areDescriptionValidSizes(final Collection<TimestampedEventVo> value, final ConstraintValidatorContext context) {
+        final int maxDescriptionLength = parameters.getMaxDescriptionLength();
+        final Optional<String> firstRejectedDescription = getFirstAttributeWithInvalidLength(value, TimestampedEventVo::getDescription, maxDescriptionLength);
+
+        if (firstRejectedDescription.isPresent()) {
+            contextConfigurer(context, DESCRIPTION_TOO_LONG_ERROR_MESSAGE, timestampedEventCollectionType, maxDescriptionLength, firstRejectedDescription.get().length());
+            return false;
+        }
+        return true;
+    }
+
+    private Optional<String> getFirstAttributeWithInvalidLength(final Collection<TimestampedEventVo> value, final Function<TimestampedEventVo, String> attributeAccessor, final int maxLengthAllowed) {
+        return value.stream()
+                .map(attributeAccessor)
+                .filter(Objects::nonNull)
+                .filter(n -> n.length() > maxLengthAllowed)
+                .findFirst();
+    }
+
+    private void contextConfigurer(final ConstraintValidatorContext context, final String messageTemplate, final Object... messageParameters) {
+        final String errorMessage = String.format(messageTemplate, messageParameters);
+        context.disableDefaultConstraintViolation();
+        context.buildConstraintViolationWithTemplate(errorMessage).addConstraintViolation();
+    }
+}
diff --git a/analytics-server/src/main/java/fr/gouv/tac/analytics/server/controller/AnalyticsController.java b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/controller/AnalyticsController.java
new file mode 100644
index 0000000000000000000000000000000000000000..cd34ee04990d162d4cb724df9cffefd116350290
--- /dev/null
+++ b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/controller/AnalyticsController.java
@@ -0,0 +1,32 @@
+package fr.gouv.tac.analytics.server.controller;
+
+import javax.inject.Inject;
+import javax.validation.Valid;
+
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import fr.gouv.tac.analytics.server.controller.mapper.AnalyticsMapper;
+import fr.gouv.tac.analytics.server.controller.vo.AnalyticsVo;
+import fr.gouv.tac.analytics.server.service.AnalyticsService;
+import lombok.RequiredArgsConstructor;
+
+
+@RestController
+@RequestMapping(value = "${analyticsserver.controller.analytics.path}")
+@RequiredArgsConstructor(onConstructor = @__(@Inject))
+public class AnalyticsController {
+
+    private final AnalyticsService analyticsService;
+    private final AnalyticsMapper analyticsMapper;
+
+    @PostMapping
+    public ResponseEntity<Void> addAnalytics(@Valid @RequestBody final AnalyticsVo analyticsVo) {
+        analyticsMapper.map(analyticsVo).ifPresent(analyticsService::createAnalytics);
+        return ResponseEntity.ok().build();
+    }
+
+}
diff --git a/analytics-server/src/main/java/fr/gouv/tac/analytics/server/controller/CustomExceptionHandler.java b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/controller/CustomExceptionHandler.java
new file mode 100644
index 0000000000000000000000000000000000000000..896b7212d42d5e0d99bfad1ba57634c9a330784d
--- /dev/null
+++ b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/controller/CustomExceptionHandler.java
@@ -0,0 +1,43 @@
+package fr.gouv.tac.analytics.server.controller;
+
+import fr.gouv.tac.analytics.server.controller.vo.ErrorVo;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+import java.time.ZonedDateTime;
+
+@Slf4j
+@RestControllerAdvice
+public class CustomExceptionHandler {
+
+
+    @ExceptionHandler(value = AuthenticationException.class)
+    public ResponseEntity<ErrorVo> exception(final AuthenticationException e) {
+        return errorVoBuilder(e, HttpStatus.UNAUTHORIZED);
+    }
+
+    @ExceptionHandler(value = MethodArgumentNotValidException.class)
+    public ResponseEntity<ErrorVo> exception(final MethodArgumentNotValidException e) {
+        return errorVoBuilder(e, HttpStatus.BAD_REQUEST);
+    }
+
+
+    @ExceptionHandler(value = Exception.class)
+    public ResponseEntity<ErrorVo> exception(final Exception e) {
+        log.warn("Unexpected error :", e);
+        return errorVoBuilder(e, HttpStatus.INTERNAL_SERVER_ERROR);
+    }
+
+    private ResponseEntity<ErrorVo> errorVoBuilder(final Exception e, final HttpStatus httpStatus) {
+        final ErrorVo errorVo = ErrorVo.builder()
+                .message(e.getMessage())
+                .timestamp(ZonedDateTime.now())
+                .build();
+        return ResponseEntity.status(httpStatus).body(errorVo);
+    }
+}
diff --git a/analytics-server/src/main/java/fr/gouv/tac/analytics/server/controller/mapper/AnalyticsMapper.java b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/controller/mapper/AnalyticsMapper.java
new file mode 100644
index 0000000000000000000000000000000000000000..a2c7efb15a8682e6dcfb584d50c9beb17514ebef
--- /dev/null
+++ b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/controller/mapper/AnalyticsMapper.java
@@ -0,0 +1,22 @@
+package fr.gouv.tac.analytics.server.controller.mapper;
+
+import fr.gouv.tac.analytics.server.controller.vo.AnalyticsVo;
+import fr.gouv.tac.analytics.server.model.kafka.Analytics;
+import org.mapstruct.Mapper;
+import org.mapstruct.Mapping;
+
+import java.util.Optional;
+
+
+@Mapper(componentModel = "spring", uses = TimestampedEventMapper.class)
+public interface AnalyticsMapper {
+
+
+    default Optional<Analytics> map(final AnalyticsVo analyticsVo) {
+        return Optional.ofNullable(this.mapInternal(analyticsVo));
+    }
+
+    @Mapping(target = "creationDate", expression = "java( java.time.ZonedDateTime.now() )")
+    Analytics mapInternal(final AnalyticsVo analyticsVo);
+
+}
diff --git a/analytics-server/src/main/java/fr/gouv/tac/analytics/server/controller/mapper/TimestampedEventMapper.java b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/controller/mapper/TimestampedEventMapper.java
new file mode 100644
index 0000000000000000000000000000000000000000..2ec990fa3b98fc8fdd2661446e5ffe115d633743
--- /dev/null
+++ b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/controller/mapper/TimestampedEventMapper.java
@@ -0,0 +1,14 @@
+package fr.gouv.tac.analytics.server.controller.mapper;
+
+import fr.gouv.tac.analytics.server.model.kafka.TimestampedEvent;
+import fr.gouv.tac.analytics.server.controller.vo.TimestampedEventVo;
+import org.mapstruct.Mapper;
+
+@Mapper(componentModel = "spring")
+public interface TimestampedEventMapper {
+
+    TimestampedEvent map(TimestampedEventVo timestampedEventVo);
+
+}
+
+
diff --git a/analytics-server/src/main/java/fr/gouv/tac/analytics/server/controller/vo/AnalyticsVo.java b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/controller/vo/AnalyticsVo.java
new file mode 100644
index 0000000000000000000000000000000000000000..756c9342a4934c6d4fdb6f0a629bda5394ba4194
--- /dev/null
+++ b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/controller/vo/AnalyticsVo.java
@@ -0,0 +1,39 @@
+package fr.gouv.tac.analytics.server.controller.vo;
+
+
+import java.util.List;
+import java.util.Map;
+
+import javax.validation.Valid;
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.Size;
+
+import fr.gouv.tac.analytics.server.config.validation.validator.AnalyticsVoInfoSize;
+import fr.gouv.tac.analytics.server.config.validation.validator.TimestampedEventCollection;
+import fr.gouv.tac.analytics.server.config.validation.validator.TimestampedEventCollectionType;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@AllArgsConstructor
+@NoArgsConstructor
+@Data
+@Builder
+public class AnalyticsVo {
+
+    @NotBlank
+    @Size(max = 64)
+    private String installationUuid;
+
+    @AnalyticsVoInfoSize
+    private Map<String, String> infos;
+
+    @Valid
+    @TimestampedEventCollection(type = TimestampedEventCollectionType.EVENT)
+    private List<TimestampedEventVo> events;
+
+    @Valid
+    @TimestampedEventCollection(type = TimestampedEventCollectionType.ERROR)
+    private List<TimestampedEventVo> errors;
+}
diff --git a/analytics-server/src/main/java/fr/gouv/tac/analytics/server/controller/vo/ErrorVo.java b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/controller/vo/ErrorVo.java
new file mode 100644
index 0000000000000000000000000000000000000000..53be8cb7fea692f6a5cfe1870e238f3dc331ff16
--- /dev/null
+++ b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/controller/vo/ErrorVo.java
@@ -0,0 +1,19 @@
+package fr.gouv.tac.analytics.server.controller.vo;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.time.ZonedDateTime;
+
+@AllArgsConstructor
+@NoArgsConstructor
+@Data
+@Builder
+public class ErrorVo {
+
+    private String message;
+
+    private ZonedDateTime timestamp;
+}
diff --git a/analytics-server/src/main/java/fr/gouv/tac/analytics/server/controller/vo/TimestampedEventVo.java b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/controller/vo/TimestampedEventVo.java
new file mode 100644
index 0000000000000000000000000000000000000000..c90fcf4dd9e53092bcb8973f89140512f778eef5
--- /dev/null
+++ b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/controller/vo/TimestampedEventVo.java
@@ -0,0 +1,26 @@
+package fr.gouv.tac.analytics.server.controller.vo;
+
+import java.time.ZonedDateTime;
+
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.NotNull;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@AllArgsConstructor
+@NoArgsConstructor
+@Data
+@Builder
+public class TimestampedEventVo {
+
+    @NotBlank
+    private String name;
+
+    @NotNull
+    private ZonedDateTime timestamp;
+
+    private String description;
+}
diff --git a/analytics-server/src/main/java/fr/gouv/tac/analytics/server/model/kafka/Analytics.java b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/model/kafka/Analytics.java
new file mode 100644
index 0000000000000000000000000000000000000000..2bcdb8e3eb1e208d15b1e9e7db31800eabfbadee
--- /dev/null
+++ b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/model/kafka/Analytics.java
@@ -0,0 +1,27 @@
+package fr.gouv.tac.analytics.server.model.kafka;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.time.ZonedDateTime;
+import java.util.List;
+import java.util.Map;
+
+@AllArgsConstructor
+@NoArgsConstructor
+@Data
+@Builder
+public class Analytics {
+
+    private String installationUuid;
+
+    private Map<String, String> infos;
+
+    private List<TimestampedEvent> events;
+
+    private List<TimestampedEvent> errors;
+
+    private ZonedDateTime creationDate;
+}
diff --git a/analytics-server/src/main/java/fr/gouv/tac/analytics/server/model/kafka/TimestampedEvent.java b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/model/kafka/TimestampedEvent.java
new file mode 100644
index 0000000000000000000000000000000000000000..00c7c41eb3c1aebabe3ee5d27606262f88c3d4e8
--- /dev/null
+++ b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/model/kafka/TimestampedEvent.java
@@ -0,0 +1,21 @@
+package fr.gouv.tac.analytics.server.model.kafka;
+
+import java.time.ZonedDateTime;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@AllArgsConstructor
+@NoArgsConstructor
+@Data
+@Builder
+public class TimestampedEvent {
+
+    private String name;
+
+    private ZonedDateTime timestamp;
+
+    private String description;
+}
diff --git a/analytics-server/src/main/java/fr/gouv/tac/analytics/server/model/mongo/TokenIdentifier.java b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/model/mongo/TokenIdentifier.java
new file mode 100644
index 0000000000000000000000000000000000000000..aab70e8decd54e527b69e05eec9ef9dbd88fd975
--- /dev/null
+++ b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/model/mongo/TokenIdentifier.java
@@ -0,0 +1,34 @@
+package fr.gouv.tac.analytics.server.model.mongo;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import org.springframework.data.annotation.Id;
+import org.springframework.data.mongodb.core.mapping.Document;
+import org.springframework.data.mongodb.core.mapping.Field;
+
+import javax.validation.constraints.NotNull;
+import java.time.ZonedDateTime;
+
+@AllArgsConstructor
+@NoArgsConstructor
+@Data
+@Builder
+@Document(value = "tokenIdentifier")
+public class TokenIdentifier {
+
+    public static final String EXPIRATION_DATE_FIELD_NAME = "expirationDate";
+    public static final String IDENTIFIER_FIELD_NAME = "identifier";
+
+    @Id
+    private String id;
+
+    @NotNull
+    @Field(name = IDENTIFIER_FIELD_NAME)
+    private String identifier;
+
+    @NotNull
+    @Field(name = EXPIRATION_DATE_FIELD_NAME)
+    private ZonedDateTime expirationDate;
+}
diff --git a/analytics-server/src/main/java/fr/gouv/tac/analytics/server/repository/mongo/TokenIdentifierRepository.java b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/repository/mongo/TokenIdentifierRepository.java
new file mode 100644
index 0000000000000000000000000000000000000000..987015b3dc9b2a22a9fb33b0092f6416f86a3e70
--- /dev/null
+++ b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/repository/mongo/TokenIdentifierRepository.java
@@ -0,0 +1,13 @@
+package fr.gouv.tac.analytics.server.repository.mongo;
+
+import fr.gouv.tac.analytics.server.model.mongo.TokenIdentifier;
+
+import org.springframework.data.mongodb.repository.MongoRepository;
+
+import java.util.Optional;
+
+public interface TokenIdentifierRepository extends MongoRepository<TokenIdentifier, String> {
+
+
+    Optional<TokenIdentifier> findByIdentifier(String identifier);
+}
diff --git a/analytics-server/src/main/java/fr/gouv/tac/analytics/server/service/AnalyticsService.java b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/service/AnalyticsService.java
new file mode 100644
index 0000000000000000000000000000000000000000..a9650f117a71e5bc0856d1b16345a0f6fa9a7fea
--- /dev/null
+++ b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/service/AnalyticsService.java
@@ -0,0 +1,33 @@
+package fr.gouv.tac.analytics.server.service;
+
+import fr.gouv.tac.analytics.server.model.kafka.Analytics;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.kafka.core.KafkaTemplate;
+import org.springframework.kafka.support.SendResult;
+import org.springframework.stereotype.Service;
+import org.springframework.util.concurrent.ListenableFutureCallback;
+
+import javax.inject.Inject;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor(onConstructor = @__(@Inject))
+public class AnalyticsService {
+
+    private final KafkaTemplate<String, Analytics> kafkaTemplate;
+
+    public void createAnalytics(final Analytics analytics) {
+        kafkaTemplate.sendDefault(analytics).addCallback(new ListenableFutureCallback<>() {
+            @Override
+            public void onFailure(final Throwable throwable) {
+                log.warn("Error sending message to kafka", throwable);
+            }
+
+            @Override
+            public void onSuccess(final SendResult<String, Analytics> sendResult) {
+                log.debug("Message successfully sent {}", sendResult);
+            }
+        });
+    }
+}
diff --git a/analytics-server/src/main/java/fr/gouv/tac/analytics/server/service/TokenIdentifierService.java b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/service/TokenIdentifierService.java
new file mode 100644
index 0000000000000000000000000000000000000000..72a44d76cecffccb60ebbdc5b2f62750e4e7ba70
--- /dev/null
+++ b/analytics-server/src/main/java/fr/gouv/tac/analytics/server/service/TokenIdentifierService.java
@@ -0,0 +1,39 @@
+package fr.gouv.tac.analytics.server.service;
+
+import fr.gouv.tac.analytics.server.model.mongo.TokenIdentifier;
+import fr.gouv.tac.analytics.server.repository.mongo.TokenIdentifierRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.inject.Inject;
+import java.time.ZonedDateTime;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor(onConstructor = @__(@Inject))
+public class TokenIdentifierService {
+
+    private final TokenIdentifierRepository tokenIdentifierRepository;
+
+    @Transactional(readOnly = true)
+    public boolean tokenIdentifierExist(final String tokenIdentifier) {
+        return tokenIdentifierRepository.findByIdentifier(tokenIdentifier).isPresent();
+    }
+
+    @Transactional
+    public TokenIdentifier save(final String tokenIdentifier, final ZonedDateTime expirationDate) {
+
+        final TokenIdentifier tokenIdentifierToSave = TokenIdentifier.builder()
+                .identifier(tokenIdentifier)
+                .expirationDate(expirationDate)
+                .build();
+
+        final TokenIdentifier savedTokenIdentifier = tokenIdentifierRepository.save(tokenIdentifierToSave);
+        log.debug("TokenIdentifier saved : {}", savedTokenIdentifier);
+
+        return savedTokenIdentifier;
+    }
+
+}
diff --git a/analytics-server/src/main/resources/application-dev.properties b/analytics-server/src/main/resources/application-dev.properties
new file mode 100644
index 0000000000000000000000000000000000000000..36d3e900eb745bec1f16c99f861f753b40321b99
--- /dev/null
+++ b/analytics-server/src/main/resources/application-dev.properties
@@ -0,0 +1,20 @@
+server.port=8089
+spring.data.mongodb.uri=mongodb://localhost:27018/protectedAppAnalyticsDB
+analyticsserver.robert_jwt_analyticspublickey=MIGeMA0GCSqGSIb3DQEBAQUAA4GMADCBiAKBgHoDa/ff6ZfDzUSgjo+AbYhgthpgaBEX3L6YFRr+QfVwa5A7sxp/aze9gKgN8JlaCtNV2M7cor96fLMV71ucWUuPeyZf5CBMokETU9L8AD44KEZDyZFdGLydV3F7mTPIkcQlTjMqWF5V/l7NOQY0FvQpOYZ5Eosi19qsZyeeU5izAgMBAAE=
+# related private key
+# MIICWgIBAAKBgHoDa/ff6ZfDzUSgjo+AbYhgthpgaBEX3L6YFRr+QfVwa5A7sxp/
+# aze9gKgN8JlaCtNV2M7cor96fLMV71ucWUuPeyZf5CBMokETU9L8AD44KEZDyZFd
+# GLydV3F7mTPIkcQlTjMqWF5V/l7NOQY0FvQpOYZ5Eosi19qsZyeeU5izAgMBAAEC
+# gYADi+/qf565o55m3UfnkfMdILqKX70GqivnemI6q6NdMAxgX+vf3E5Qi3ie6fDR
+# dFWWOQuZT76HVFclmYCgqBXMWwTYTaAHchJvDty7CtAMlr835VWps5hQLy+NvDDj
+# 6HmH/9qpp6rk6mFvw/2/scQgF27BlSB69KF0fvd0l9qpeQJBANWHlZ1eOftSy8XO
+# w0ZZIZuvMTLN9Z2Hypziqa1PubBI5R6rhlhpVctrx3gJErXZaIxt9R+n2BDLk44J
+# asj2nacCQQCSSAyZFp7yUVJ1MPNIHkF1qYIcdvehuyvamGcVj3ouIUx/FpRRY7vn
+# 6++zDVKRNnEyyYzLxLmj7tSzrCRmu0YVAkBx3AR5j38XdoTWC3SxFGIJZBe14YEw
+# 6PGvSmXz1mlLiPMzsX7HshNfjy8j4KKreSx4hUGKCbP68LLTsE3Srz5NAkBilSng
+# Vg1ikwm2LvoVxUYqTMWB928l0OVaGVGHhz05L3nFQGtOep9dSnUtPzJA6Oba6lf3
+# z6moYEF6oO2bSmSNAkBB0sE2OcivffQDWs9Z2zdRbdBWLofQeWypmAPGOAqRE5HE
+# RLPXHTKS4C1JFeotdXJ3bRrl1sODweOQghINCTav
+# generated with : https://travistidwell.com/jsencrypt/demo/
+spring.kafka.bootstrap-servers=localhost:19092
+spring.kafka.template.default-topic=analyticsTopic
diff --git a/analytics-server/src/main/resources/application.properties b/analytics-server/src/main/resources/application.properties
new file mode 100644
index 0000000000000000000000000000000000000000..4dfccf2202ba27626c476b5f38776d4d4af85ba9
--- /dev/null
+++ b/analytics-server/src/main/resources/application.properties
@@ -0,0 +1,22 @@
+server.port=${ANALYTICS_SERVER_PORT:8089}
+## Use uri to be able to connect to replica sets / sharded clusters
+spring.data.mongodb.uri=mongodb://${ANALYTICS_SERVER_DB_HOST}\:${ANALYTICS_SERVER_DB_PORT}/${ANALYTICS_SERVER_DB_NAME:protectedAppAnalyticsDB}
+spring.data.mongodb.auto-index-creation=false
+# Kafka producer conf
+spring.kafka.bootstrap-servers=${ANALYTICS_KAFKA_BOOTSTRAP_SERVERS}
+spring.kafka.template.default-topic=${ANALYTICS_KAFKA_DEFAULT_TOPIC:analyticsTopic}
+# Available endpoints for the monitoring
+management.endpoints.web.exposure.include=${ANALYTICS_SERVER_MONITORING_ENDPOINTS:health,metrics,info,prometheus}
+analyticsserver.controller.path.prefix=/api/v1
+analyticsserver.controller.analytics.path=${analyticsserver.controller.path.prefix}/analytics
+analyticsserver.robert_jwt_analyticspublickey=${ANALYTICS_JWT_PUBLIC_KEY}
+analyticsserver.validation.analytics.information.maxInfoAllowed=${ANALYTICS_VALIDATION_ANALYTICS_INFO_MAX_ELEMENT:20}
+analyticsserver.validation.analytics.information.maxInfoKeyLength=${ANALYTICS_VALIDATION_ANALYTICS_INFO_MAX_KEY_LENGTH:64}
+analyticsserver.validation.analytics.information.maxInfoValueLength=${ANALYTICS_VALIDATION_ANALYTICS_INFO_MAX_VALUE_LENGTH:512}
+analyticsserver.validation.analytics.event.maxElementAllowed=${ANALYTICS_VALIDATION_ANALYTICS_EVENT_MAX_ELEMENT:20}
+analyticsserver.validation.analytics.event.maxNameLength=${ANALYTICS_VALIDATION_ANALYTICS_EVENT_MAX_NAME_LENGTH:64}
+analyticsserver.validation.analytics.event.maxDescriptionLength=${ANALYTICS_VALIDATION_ANALYTICS_EVENT_MAX_DESCRIPTION_LENGTH:512}
+analyticsserver.validation.analytics.error.maxElementAllowed=${ANALYTICS_VALIDATION_ANALYTICS_ERROR_MAX_ELEMENT:20}
+analyticsserver.validation.analytics.error.maxNameLength=${ANALYTICS_VALIDATION_ANALYTICS_ERROR_MAX_NAME_LENGTH:64}
+analyticsserver.validation.analytics.error.maxDescriptionLength=${ANALYTICS_VALIDATION_ANALYTICS_ERROR_MAX_DESCRIPTION_LENGTH:512}
+
diff --git a/analytics-server/src/main/resources/bootstrap.yml b/analytics-server/src/main/resources/bootstrap.yml
new file mode 100644
index 0000000000000000000000000000000000000000..d2ea314042569782d352e6038822d5294788e67a
--- /dev/null
+++ b/analytics-server/src/main/resources/bootstrap.yml
@@ -0,0 +1,17 @@
+spring:
+  application:
+    name: analytics-server
+  cloud:
+    consul:
+      enabled: ${CONSUL_ENABLED:false}
+      host: ${CONSUL_HOST:localhost}
+      port: ${CONSUL_PORT:8500}
+      scheme: ${CONSUL_SCHEME:http}
+      config:
+        enabled: ${CONSUL_CONFIG_ENABLED:false}
+    vault:
+      enabled: ${VAULT_ENABLED:false}
+      host: ${VAULT_HOST:localhost}
+      port: ${VAULT_PORT:8200}
+      token: ${VAULT_TOKEN:token}
+      scheme: ${VAULT_SCHEME:http}
diff --git a/analytics-server/src/main/resources/logback.xml b/analytics-server/src/main/resources/logback.xml
new file mode 100644
index 0000000000000000000000000000000000000000..78fd04ab13bc727c6ac80cece8fbcd56768da8ac
--- /dev/null
+++ b/analytics-server/src/main/resources/logback.xml
@@ -0,0 +1,55 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<configuration>
+    <include resource="org/springframework/boot/logging/logback/defaults.xml"/>
+    <property name="LOG_DIR" value="${ROBERT_SERVER_LOG_FILE_PATH:-/tmp/logs}"/>
+    <property name="LOG_FILENAME" value="${ROBERT_SERVER_LOG_FILE_NAME:-analyticsMongo-server-ws-rest}"/>
+    <property name="ERROR_LOG_FILENAME"
+              value="${ROBERT_SERVER_ERROR_LOG_FILE_NAME:-analyticsMongo-server-ws-rest}.error"/>
+
+    <appender name="RollingFile"
+              class="ch.qos.logback.core.rolling.RollingFileAppender">
+        <file>${LOG_DIR}/${LOG_FILENAME}.log</file>
+        <encoder
+                class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
+            <Pattern>%d %p %C{1.} [%t][%file:%line] %m%n</Pattern>
+        </encoder>
+
+        <rollingPolicy
+                class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
+            <!-- rollover daily and when the file reaches 10 MegaBytes -->
+            <fileNamePattern>${LOG_DIR}/${LOG_FILENAME}.%d{yyyy-MM-dd}.%i.log.gz
+            </fileNamePattern>
+            <maxFileSize>10MB</maxFileSize>
+        </rollingPolicy>
+    </appender>
+
+    <appender name="RollingErrorFile"
+              class="ch.qos.logback.core.rolling.RollingFileAppender">
+        <file>${LOG_DIR}/${ERROR_LOG_FILENAME}.log</file>
+        <encoder
+                class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
+            <Pattern>%d %p %C{1.} [%t][%file:%line] %m%n</Pattern>
+        </encoder>
+
+        <rollingPolicy
+                class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
+            <!-- rollover daily and when the file reaches 10 MegaBytes -->
+            <fileNamePattern>${LOG_DIR}/${ERROR_LOG_FILENAME}.%d{yyyy-MM-dd}.%i.log.gz
+            </fileNamePattern>
+            <maxFileSize>10MB</maxFileSize>
+        </rollingPolicy>
+    </appender>
+
+    <springProfile name="dev">
+        <include resource="org/springframework/boot/logging/logback/base.xml"/>
+        <logger name="org.springframework" level="INFO"/>
+    </springProfile>
+
+    <springProfile name="!dev">
+        <!-- LOG everything at INFO level -->
+        <root level="info">
+            <appender-ref ref="RollingFile"/>
+        </root>
+    </springProfile>
+
+</configuration>
diff --git a/analytics-server/src/test/java/fr/gouv/tac/analytics/server/config/mongodb/converters/DateToZonedDateTimeConverterTest.java b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/config/mongodb/converters/DateToZonedDateTimeConverterTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..f2633312d13b3918db4e1264a937800ab2441c62
--- /dev/null
+++ b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/config/mongodb/converters/DateToZonedDateTimeConverterTest.java
@@ -0,0 +1,33 @@
+package fr.gouv.tac.analytics.server.config.mongodb.converters;
+
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.util.Date;
+import java.util.GregorianCalendar;
+
+import org.springframework.test.context.junit.jupiter.SpringExtension;
+
+import org.assertj.core.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+@ExtendWith(SpringExtension.class)
+public class DateToZonedDateTimeConverterTest {
+
+    private final DateToZonedDateTimeConverter dateToZonedDateTimeConverter = new DateToZonedDateTimeConverter();
+
+    @Test
+    public void shouldConvertDateToZonedDateTimeWithUTCTimeZone() {
+
+        final ZonedDateTime zdt = ZonedDateTime.parse("2019-04-01T16:24:11.252+02:00");
+        final GregorianCalendar calendar = GregorianCalendar.from(zdt);
+
+        final Date date = calendar.getTime();
+
+        final ZonedDateTime result = dateToZonedDateTimeConverter.convert(date);
+        Assertions.assertThat(result.getZone()).isEqualTo(ZoneId.of("UTC").normalized());
+        Assertions.assertThat(result).isEqualToIgnoringNanos(zdt);
+
+    }
+
+}
\ No newline at end of file
diff --git a/analytics-server/src/test/java/fr/gouv/tac/analytics/server/config/mongodb/converters/ZonedDateTimeToDateConverterTest.java b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/config/mongodb/converters/ZonedDateTimeToDateConverterTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..58c6da636b98fd1ad80a7e77d7ac85e399dc1404
--- /dev/null
+++ b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/config/mongodb/converters/ZonedDateTimeToDateConverterTest.java
@@ -0,0 +1,26 @@
+package fr.gouv.tac.analytics.server.config.mongodb.converters;
+
+import org.assertj.core.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.springframework.test.context.junit.jupiter.SpringExtension;
+
+import java.time.ZonedDateTime;
+import java.util.Date;
+
+@ExtendWith(SpringExtension.class)
+public class ZonedDateTimeToDateConverterTest {
+
+    private final ZonedDateTimeToDateConverter zonedDateTimeToDateConverter = new ZonedDateTimeToDateConverter();
+
+    @Test
+    public void shouldMapZoneDateTimeToDate() {
+
+        final ZonedDateTime zdt = ZonedDateTime.parse("2019-04-01T16:24:11.252+02:00");
+
+        final Date result = zonedDateTimeToDateConverter.convert(zdt);
+
+        Assertions.assertThat(result).isEqualTo("2019-04-01T16:24:11.252+0200");
+
+    }
+}
\ No newline at end of file
diff --git a/analytics-server/src/test/java/fr/gouv/tac/analytics/server/config/security/oauth2tokenvalidator/DelegatingOAuth2JwtTokenValidatorTest.java b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/config/security/oauth2tokenvalidator/DelegatingOAuth2JwtTokenValidatorTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..8bd75e1845cb7018a0188c876216f153f29f90b4
--- /dev/null
+++ b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/config/security/oauth2tokenvalidator/DelegatingOAuth2JwtTokenValidatorTest.java
@@ -0,0 +1,66 @@
+package fr.gouv.tac.analytics.server.config.security.oauth2tokenvalidator;
+
+import org.assertj.core.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InOrder;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.springframework.security.oauth2.core.OAuth2Error;
+import org.springframework.security.oauth2.core.OAuth2TokenValidator;
+import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.test.context.junit.jupiter.SpringExtension;
+
+@ExtendWith(SpringExtension.class)
+public class DelegatingOAuth2JwtTokenValidatorTest {
+
+    @Mock
+    private OAuth2TokenValidator<Jwt> validator1;
+
+    @Mock
+    private OAuth2TokenValidator<Jwt> validator2;
+
+    @Mock
+    private Jwt token;
+
+    private DelegatingOAuth2JwtTokenValidator delegatingOAuth2JwtTokenValidator;
+
+    @BeforeEach
+    public void setUp() {
+        delegatingOAuth2JwtTokenValidator = new DelegatingOAuth2JwtTokenValidator(validator1, validator2);
+    }
+
+    @Test
+    public void shouldCallEachValidatorIfNoError() {
+
+        Mockito.when(validator1.validate(token)).thenReturn(OAuth2TokenValidatorResult.success());
+        Mockito.when(validator2.validate(token)).thenReturn(OAuth2TokenValidatorResult.success());
+
+        final OAuth2TokenValidatorResult result = delegatingOAuth2JwtTokenValidator.validate(token);
+
+        Assertions.assertThat(result.hasErrors()).isFalse();
+
+        final InOrder inOrder = Mockito.inOrder(validator1, validator2);
+
+        inOrder.verify(validator1, Mockito.times(1)).validate(token);
+        inOrder.verify(validator2, Mockito.times(1)).validate(token);
+    }
+
+    @Test
+    public void shouldStopValidationWhenAnErrorIsEncountered() {
+
+        final OAuth2TokenValidatorResult failure = OAuth2TokenValidatorResult.failure(new OAuth2Error("errorCode", "error message", "some uri"));
+
+        Mockito.when(validator1.validate(token)).thenReturn(failure);
+
+        final OAuth2TokenValidatorResult result = delegatingOAuth2JwtTokenValidator.validate(token);
+
+        Assertions.assertThat(result.hasErrors()).isTrue();
+        Assertions.assertThat(result.getErrors()).containsExactlyInAnyOrderElementsOf(failure.getErrors());
+
+        Mockito.verify(validator2, Mockito.never()).validate(token);
+    }
+
+}
\ No newline at end of file
diff --git a/analytics-server/src/test/java/fr/gouv/tac/analytics/server/config/security/oauth2tokenvalidator/ExpirationTokenPresenceOAuth2TokenValidatorTest.java b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/config/security/oauth2tokenvalidator/ExpirationTokenPresenceOAuth2TokenValidatorTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..854c3b5631d838b53c40909d82ee527f2263d0e9
--- /dev/null
+++ b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/config/security/oauth2tokenvalidator/ExpirationTokenPresenceOAuth2TokenValidatorTest.java
@@ -0,0 +1,50 @@
+package fr.gouv.tac.analytics.server.config.security.oauth2tokenvalidator;
+
+import java.time.Instant;
+
+import org.springframework.security.oauth2.core.OAuth2Error;
+import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.test.context.junit.jupiter.SpringExtension;
+
+import org.assertj.core.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+
+
+@ExtendWith(SpringExtension.class)
+public class ExpirationTokenPresenceOAuth2TokenValidatorTest {
+
+    @Mock
+    private Jwt token;
+
+    private final ExpirationTokenPresenceOAuth2TokenValidator expirationTokenPresenceOAuth2TokenValidator = new ExpirationTokenPresenceOAuth2TokenValidator();
+
+    @Test
+    public void shouldFailIfNoJtiInTheToken() {
+
+        Mockito.when(token.getExpiresAt()).thenReturn(null);
+
+        final OAuth2TokenValidatorResult result = expirationTokenPresenceOAuth2TokenValidator.validate(token);
+
+        Assertions.assertThat(result.hasErrors()).isTrue();
+        Assertions.assertThat(result.getErrors()).hasSize(1);
+        final OAuth2Error oAuth2ErrorResult = result.getErrors().iterator().next();
+        Assertions.assertThat(oAuth2ErrorResult.getErrorCode()).isEqualTo(ExpirationTokenPresenceOAuth2TokenValidator.EXPIRATION_NOT_FOUND_OAUTH2ERROR.getErrorCode());
+        Assertions.assertThat(oAuth2ErrorResult.getDescription()).isEqualTo(ExpirationTokenPresenceOAuth2TokenValidator.EXPIRATION_NOT_FOUND_OAUTH2ERROR.getDescription());
+        Assertions.assertThat(oAuth2ErrorResult.getUri()).isNull();
+    }
+
+    @Test
+    public void shouldNotFailIfExpirationTokenIsPresent() {
+
+        Mockito.when(token.getExpiresAt()).thenReturn(Instant.now());
+
+        final OAuth2TokenValidatorResult result = expirationTokenPresenceOAuth2TokenValidator.validate(token);
+
+        Assertions.assertThat(result.hasErrors()).isFalse();
+    }
+
+}
\ No newline at end of file
diff --git a/analytics-server/src/test/java/fr/gouv/tac/analytics/server/config/security/oauth2tokenvalidator/JtiCanOnlyBeUsedOnceOAuth2TokenValidatorTest.java b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/config/security/oauth2tokenvalidator/JtiCanOnlyBeUsedOnceOAuth2TokenValidatorTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..f8af38b4dad483c46011d8e2fd4388d1096fa225
--- /dev/null
+++ b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/config/security/oauth2tokenvalidator/JtiCanOnlyBeUsedOnceOAuth2TokenValidatorTest.java
@@ -0,0 +1,68 @@
+package fr.gouv.tac.analytics.server.config.security.oauth2tokenvalidator;
+
+
+import fr.gouv.tac.analytics.server.service.TokenIdentifierService;
+import fr.gouv.tac.analytics.server.model.mongo.TokenIdentifier;
+import org.assertj.core.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.springframework.security.oauth2.core.OAuth2Error;
+import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.test.context.junit.jupiter.SpringExtension;
+
+import java.time.ZonedDateTime;
+
+@ExtendWith(SpringExtension.class)
+public class JtiCanOnlyBeUsedOnceOAuth2TokenValidatorTest {
+
+    @Mock
+    private Jwt token;
+
+    @Mock
+    private TokenIdentifierService tokenIdentifierService;
+
+    @InjectMocks
+    private JtiCanOnlyBeUsedOnceOAuth2TokenValidator jtiCanOnlyBeUsedOnceOAuth2TokenValidator;
+
+
+    @Test
+    public void shouldFailIfTokenIdentifierAlreadyExist() {
+
+        final String jti = "someId";
+        final ZonedDateTime expirationDate = ZonedDateTime.now();
+
+        Mockito.when(token.getId()).thenReturn(jti);
+        Mockito.when(tokenIdentifierService.tokenIdentifierExist(jti)).thenReturn(true);
+
+        final OAuth2TokenValidatorResult result = jtiCanOnlyBeUsedOnceOAuth2TokenValidator.validate(token);
+
+        Assertions.assertThat(result.hasErrors()).isTrue();
+        Assertions.assertThat(result.getErrors()).hasSize(1);
+        final OAuth2Error oAuth2ErrorResult = result.getErrors().iterator().next();
+        Assertions.assertThat(oAuth2ErrorResult.getErrorCode()).isEqualTo(JtiCanOnlyBeUsedOnceOAuth2TokenValidator.JTI_USED_MORE_THAN_ONCE_OAUTH2ERROR.getErrorCode());
+        Assertions.assertThat(oAuth2ErrorResult.getDescription()).isEqualTo(JtiCanOnlyBeUsedOnceOAuth2TokenValidator.JTI_USED_MORE_THAN_ONCE_OAUTH2ERROR.getDescription());
+        Assertions.assertThat(oAuth2ErrorResult.getUri()).isNull();
+
+        Mockito.verify(tokenIdentifierService, Mockito.never()).save(jti, expirationDate);
+    }
+
+    @Test
+    public void shouldSuccessWhenItsTheFirstTokenIdentifierUsage() {
+
+        final String jti = "someId";
+        final ZonedDateTime expirationDate = ZonedDateTime.now();
+
+        Mockito.when(token.getId()).thenReturn(jti);
+        Mockito.when(token.getExpiresAt()).thenReturn(expirationDate.toInstant());
+        Mockito.when(tokenIdentifierService.tokenIdentifierExist(jti)).thenReturn(false);
+        Mockito.when(tokenIdentifierService.save(jti, expirationDate)).thenReturn(new TokenIdentifier());
+
+        final OAuth2TokenValidatorResult result = jtiCanOnlyBeUsedOnceOAuth2TokenValidator.validate(token);
+
+        Assertions.assertThat(result.hasErrors()).isFalse();
+    }
+}
\ No newline at end of file
diff --git a/analytics-server/src/test/java/fr/gouv/tac/analytics/server/config/security/oauth2tokenvalidator/JtiPresenceOAuth2TokenValidatorTest.java b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/config/security/oauth2tokenvalidator/JtiPresenceOAuth2TokenValidatorTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..471da5645b5b5c1fe30357acf6c04149aac38ddf
--- /dev/null
+++ b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/config/security/oauth2tokenvalidator/JtiPresenceOAuth2TokenValidatorTest.java
@@ -0,0 +1,47 @@
+package fr.gouv.tac.analytics.server.config.security.oauth2tokenvalidator;
+
+import org.assertj.core.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.springframework.security.oauth2.core.OAuth2Error;
+import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.test.context.junit.jupiter.SpringExtension;
+
+@ExtendWith(SpringExtension.class)
+public class JtiPresenceOAuth2TokenValidatorTest {
+
+    @Mock
+    private Jwt token;
+
+    private final JtiPresenceOAuth2TokenValidator jtiPresenceOAuth2TokenValidator = new JtiPresenceOAuth2TokenValidator();
+
+    @Test
+    public void shouldFailIfNoJtiInTheToken() {
+
+        Mockito.when(token.getId()).thenReturn(null);
+
+        final OAuth2TokenValidatorResult result = jtiPresenceOAuth2TokenValidator.validate(token);
+
+        Assertions.assertThat(result.hasErrors()).isTrue();
+        Assertions.assertThat(result.getErrors()).hasSize(1);
+        final OAuth2Error oAuth2ErrorResult = result.getErrors().iterator().next();
+        Assertions.assertThat(oAuth2ErrorResult.getErrorCode()).isEqualTo(JtiPresenceOAuth2TokenValidator.JTI_NOT_FOUND_OAUTH2ERROR.getErrorCode());
+        Assertions.assertThat(oAuth2ErrorResult.getDescription()).isEqualTo(JtiPresenceOAuth2TokenValidator.JTI_NOT_FOUND_OAUTH2ERROR.getDescription());
+        Assertions.assertThat(oAuth2ErrorResult.getUri()).isNull();
+    }
+
+    @Test
+    public void shouldNotFailIfJtiIsPresent() {
+
+        final String jti = "someId";
+        Mockito.when(token.getId()).thenReturn(jti);
+
+        final OAuth2TokenValidatorResult result = jtiPresenceOAuth2TokenValidator.validate(token);
+
+        Assertions.assertThat(result.hasErrors()).isFalse();
+    }
+
+}
\ No newline at end of file
diff --git a/analytics-server/src/test/java/fr/gouv/tac/analytics/server/config/validation/validator/AnalyticsMongoVoInfoSizeValidatorTest.java b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/config/validation/validator/AnalyticsMongoVoInfoSizeValidatorTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..972df7c44279e8c69cefd474c8f93d2bb6f72f4b
--- /dev/null
+++ b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/config/validation/validator/AnalyticsMongoVoInfoSizeValidatorTest.java
@@ -0,0 +1,104 @@
+package fr.gouv.tac.analytics.server.config.validation.validator;
+
+import static fr.gouv.tac.analytics.server.config.validation.validator.AnalyticsVoInfoSizeValidator.*;
+
+import java.util.Map;
+
+import javax.validation.ConstraintValidatorContext;
+
+import org.springframework.test.context.junit.jupiter.SpringExtension;
+
+import fr.gouv.tac.analytics.server.config.validation.ValidationParameters;
+import org.apache.commons.lang3.RandomStringUtils;
+import org.assertj.core.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+
+@ExtendWith(SpringExtension.class)
+public class AnalyticsMongoVoInfoSizeValidatorTest {
+
+    @Captor
+    private ArgumentCaptor<String> stringArgumentCaptor;
+
+    @Mock
+    private ConstraintValidatorContext.ConstraintViolationBuilder constraintViolationBuilder;
+
+    @Mock
+    private ConstraintValidatorContext constraintValidatorContext;
+
+    @Mock
+    private ValidationParameters validationParameters;
+
+    @InjectMocks
+    private AnalyticsVoInfoSizeValidator analyticsVoInfoSizeValidator;
+
+    private ValidationParameters.InfoValidationParameters infoValidationParameters;
+
+    @BeforeEach
+    public void setUp() {
+        infoValidationParameters = ValidationParameters.InfoValidationParameters.builder()
+                .maxInfoAllowed(3)
+                .maxInfoKeyLength(10)
+                .maxInfoValueLength(20)
+                .build();
+        Mockito.when(validationParameters.getInformation()).thenReturn(infoValidationParameters);
+    }
+
+    @Test
+    public void shouldAcceptNullMap() {
+        final Map<String, String> info = null;
+
+        final boolean result = this.analyticsVoInfoSizeValidator.isValid(info, constraintValidatorContext);
+        Assertions.assertThat(result).isTrue();
+    }
+
+    @Test
+    public void shouldAcceptMapWithLessThanMaxAllowedElement() {
+        final Map<String, String> info = Map.of("key1", "value1", "key2", "value2");
+
+        final boolean result = this.analyticsVoInfoSizeValidator.isValid(info, constraintValidatorContext);
+        Assertions.assertThat(result).isTrue();
+    }
+
+    @Test
+    public void shouldRejectMapWithMoreThanMaxAllowedElement() {
+        final Map<String, String> info = Map.of("key1", "value1", "key2", "value2", "key3", "value3", "key4", "value4");
+
+        Mockito.when(constraintValidatorContext.buildConstraintViolationWithTemplate(stringArgumentCaptor.capture())).thenReturn(constraintViolationBuilder);
+
+        final boolean result = this.analyticsVoInfoSizeValidator.isValid(info, constraintValidatorContext);
+        Assertions.assertThat(result).isFalse();
+
+        Assertions.assertThat(stringArgumentCaptor.getValue()).isEqualTo(String.format(TOO_MANY_INFO_ERROR_MESSAGE, 4, 3));
+    }
+
+    @Test
+    public void shouldRejectMapWithKeyTooLong() {
+        final Map<String, String> info = Map.of(RandomStringUtils.random(infoValidationParameters.getMaxInfoKeyLength() + 1), "value1");
+
+        Mockito.when(constraintValidatorContext.buildConstraintViolationWithTemplate(stringArgumentCaptor.capture())).thenReturn(constraintViolationBuilder);
+
+        final boolean result = this.analyticsVoInfoSizeValidator.isValid(info, constraintValidatorContext);
+        Assertions.assertThat(result).isFalse();
+
+        Assertions.assertThat(stringArgumentCaptor.getValue()).isEqualTo(String.format(KEY_TOO_LONG_ERROR_MESSAGE, 10, 11));
+    }
+
+    @Test
+    public void shouldRejectMapWithValueTooLong() {
+        final Map<String, String> info = Map.of("key1", RandomStringUtils.random(infoValidationParameters.getMaxInfoValueLength() + 1));
+
+        Mockito.when(constraintValidatorContext.buildConstraintViolationWithTemplate(stringArgumentCaptor.capture())).thenReturn(constraintViolationBuilder);
+
+        final boolean result = this.analyticsVoInfoSizeValidator.isValid(info, constraintValidatorContext);
+        Assertions.assertThat(result).isFalse();
+
+        Assertions.assertThat(stringArgumentCaptor.getValue()).isEqualTo(String.format(VALUE_TOO_LONG_ERROR_MESSAGE, 20, 21));
+    }
+}
\ No newline at end of file
diff --git a/analytics-server/src/test/java/fr/gouv/tac/analytics/server/config/validation/validator/TimestampedEventCollectionValidatorTest.java b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/config/validation/validator/TimestampedEventCollectionValidatorTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..b4f7d10d15ad289bfd9f2054b0dc2d675f65237b
--- /dev/null
+++ b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/config/validation/validator/TimestampedEventCollectionValidatorTest.java
@@ -0,0 +1,157 @@
+package fr.gouv.tac.analytics.server.config.validation.validator;
+
+import static fr.gouv.tac.analytics.server.config.validation.validator.TimestampedEventCollectionValidator.*;
+
+import java.time.ZonedDateTime;
+import java.util.Arrays;
+import java.util.Collection;
+
+import javax.validation.ConstraintValidatorContext;
+
+import org.springframework.test.context.junit.jupiter.SpringExtension;
+
+import fr.gouv.tac.analytics.server.config.validation.ValidationParameters;
+import fr.gouv.tac.analytics.server.controller.vo.TimestampedEventVo;
+import org.apache.commons.lang3.RandomStringUtils;
+import org.assertj.core.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+
+@ExtendWith(SpringExtension.class)
+public class TimestampedEventCollectionValidatorTest {
+
+    @Captor
+    private ArgumentCaptor<String> stringArgumentCaptor;
+
+    @Mock
+    private TimestampedEventCollection timestampedEventCollection;
+
+    @Mock
+    private ConstraintValidatorContext.ConstraintViolationBuilder constraintViolationBuilder;
+
+    @Mock
+    private ConstraintValidatorContext constraintValidatorContext;
+
+    @Mock
+    private ValidationParameters validationParameters;
+
+    @InjectMocks
+    private TimestampedEventCollectionValidator timestampedEventCollectionSizeValidator;
+
+    private ValidationParameters.TimestampedEventValidationParameters eventsValidationParameters;
+
+    @BeforeEach
+    public void setUp() {
+        eventsValidationParameters = ValidationParameters.TimestampedEventValidationParameters.builder()
+                .maxElementAllowed(3)
+                .maxDescriptionLength(256)
+                .maxNameLength(128)
+                .build();
+
+        Mockito.when(timestampedEventCollection.type()).thenReturn(TimestampedEventCollectionType.EVENT);
+        Mockito.when(validationParameters.getParameters(TimestampedEventCollectionType.EVENT)).thenReturn(eventsValidationParameters);
+    }
+
+
+    @Test
+    public void shouldAcceptNullCollection() {
+
+        final Collection<TimestampedEventVo> value = null;
+
+        timestampedEventCollectionSizeValidator.initialize(timestampedEventCollection);
+        final boolean result = timestampedEventCollectionSizeValidator.isValid(value, constraintValidatorContext);
+
+        Assertions.assertThat(result).isTrue();
+    }
+
+    @Test
+    public void shouldAcceptCollectionWithLessThanMaxAllowedElement() {
+
+        final Collection<TimestampedEventVo> value = Arrays.asList(timestampedEventVoBuilder());
+
+        timestampedEventCollectionSizeValidator.initialize(timestampedEventCollection);
+        final boolean result = timestampedEventCollectionSizeValidator.isValid(value, constraintValidatorContext);
+
+        Assertions.assertThat(result).isTrue();
+    }
+
+    @Test
+    public void shouldAcceptCollectionWithNullDescription() {
+        final TimestampedEventVo timestampedEventVo = timestampedEventVoBuilder();
+        timestampedEventVo.setDescription(null);
+
+        final Collection<TimestampedEventVo> value = Arrays.asList(timestampedEventVo);
+
+        Mockito.when(constraintValidatorContext.buildConstraintViolationWithTemplate(stringArgumentCaptor.capture())).thenReturn(constraintViolationBuilder);
+
+        timestampedEventCollectionSizeValidator.initialize(timestampedEventCollection);
+        final boolean result = timestampedEventCollectionSizeValidator.isValid(value, constraintValidatorContext);
+
+        Assertions.assertThat(result).isTrue();
+
+    }
+
+    @Test
+    public void shouldRejectCollectionWithTooManyElement() {
+
+        final Collection<TimestampedEventVo> value = Arrays.asList(timestampedEventVoBuilder(), timestampedEventVoBuilder(), timestampedEventVoBuilder(), timestampedEventVoBuilder(), timestampedEventVoBuilder());
+
+        Mockito.when(constraintValidatorContext.buildConstraintViolationWithTemplate(stringArgumentCaptor.capture())).thenReturn(constraintViolationBuilder);
+
+        timestampedEventCollectionSizeValidator.initialize(timestampedEventCollection);
+        final boolean result = timestampedEventCollectionSizeValidator.isValid(value, constraintValidatorContext);
+
+        Assertions.assertThat(result).isFalse();
+
+        Assertions.assertThat(stringArgumentCaptor.getValue()).isEqualTo(String.format(TOO_MANY_ELEMENTS_ERROR_MESSAGE, "EVENT", 5 , 3));
+    }
+
+    @Test
+    public void shouldRejectCollectionWithNameTooLong() {
+        final TimestampedEventVo timestampedEventVo = timestampedEventVoBuilder();
+        timestampedEventVo.setName(RandomStringUtils.random(eventsValidationParameters.getMaxNameLength() + 1));
+
+        final Collection<TimestampedEventVo> value = Arrays.asList(timestampedEventVo);
+
+        Mockito.when(constraintValidatorContext.buildConstraintViolationWithTemplate(stringArgumentCaptor.capture())).thenReturn(constraintViolationBuilder);
+
+        timestampedEventCollectionSizeValidator.initialize(timestampedEventCollection);
+        final boolean result = timestampedEventCollectionSizeValidator.isValid(value, constraintValidatorContext);
+
+        Assertions.assertThat(result).isFalse();
+
+        Assertions.assertThat(stringArgumentCaptor.getValue()).isEqualTo(String.format(NAME_TOO_LONG_ERROR_MESSAGE, "EVENT", 128, 129));
+    }
+
+    @Test
+    public void shouldRejectCollectionWithDescriptionTooLong() {
+        final TimestampedEventVo timestampedEventVo = timestampedEventVoBuilder();
+        timestampedEventVo.setDescription(RandomStringUtils.random(eventsValidationParameters.getMaxDescriptionLength() + 1));
+
+        final Collection<TimestampedEventVo> value = Arrays.asList(timestampedEventVo);
+
+        Mockito.when(constraintValidatorContext.buildConstraintViolationWithTemplate(stringArgumentCaptor.capture())).thenReturn(constraintViolationBuilder);
+
+        timestampedEventCollectionSizeValidator.initialize(timestampedEventCollection);
+        final boolean result = timestampedEventCollectionSizeValidator.isValid(value, constraintValidatorContext);
+
+        Assertions.assertThat(result).isFalse();
+
+        Assertions.assertThat(stringArgumentCaptor.getValue()).isEqualTo(String.format(DESCRIPTION_TOO_LONG_ERROR_MESSAGE, "EVENT", 256, 257));
+    }
+
+
+    private TimestampedEventVo timestampedEventVoBuilder() {
+        return TimestampedEventVo.builder()
+                .name("valid name")
+                .timestamp(ZonedDateTime.now())
+                .description("some description")
+                .build();
+    }
+}
\ No newline at end of file
diff --git a/analytics-server/src/test/java/fr/gouv/tac/analytics/server/controller/AnalyticsControllerTest.java b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/controller/AnalyticsControllerTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..52d8d1166d59a56768beefba6a7c98e24d961487
--- /dev/null
+++ b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/controller/AnalyticsControllerTest.java
@@ -0,0 +1,46 @@
+package fr.gouv.tac.analytics.server.controller;
+
+import fr.gouv.tac.analytics.server.controller.mapper.AnalyticsMapper;
+import fr.gouv.tac.analytics.server.service.AnalyticsService;
+import fr.gouv.tac.analytics.server.controller.vo.AnalyticsVo;
+import fr.gouv.tac.analytics.server.model.kafka.Analytics;
+import org.assertj.core.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.test.context.junit.jupiter.SpringExtension;
+
+import java.util.Optional;
+
+@ExtendWith(SpringExtension.class)
+public class AnalyticsControllerTest {
+
+    @Mock
+    private AnalyticsService analyticsService;
+
+    @Mock
+    private AnalyticsMapper analyticsMapper;
+
+    @InjectMocks
+    private AnalyticsController analyticsController;
+
+    @Test
+    public void shouldCreateAnalytics() {
+
+        final AnalyticsVo analyticsVo = new AnalyticsVo();
+        final Optional<Analytics> analytics = Optional.of(new Analytics());
+
+        Mockito.when(analyticsMapper.map(analyticsVo)).thenReturn(analytics);
+
+        final ResponseEntity<Void> result = analyticsController.addAnalytics(analyticsVo);
+
+        Assertions.assertThat(result).isNotNull();
+        Assertions.assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK);
+
+        Mockito.verify(analyticsService).createAnalytics(analytics.get());
+    }
+}
\ No newline at end of file
diff --git a/analytics-server/src/test/java/fr/gouv/tac/analytics/server/controller/CustomExceptionHandlerTest.java b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/controller/CustomExceptionHandlerTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..8e0e0ec6f229a3c2cf6a598af9a7503ef51767e1
--- /dev/null
+++ b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/controller/CustomExceptionHandlerTest.java
@@ -0,0 +1,63 @@
+package fr.gouv.tac.analytics.server.controller;
+
+import fr.gouv.tac.analytics.server.controller.vo.ErrorVo;
+import org.assertj.core.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
+import org.springframework.security.oauth2.core.OAuth2Error;
+import org.springframework.test.context.junit.jupiter.SpringExtension;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+
+import java.time.ZonedDateTime;
+
+@ExtendWith(SpringExtension.class)
+public class CustomExceptionHandlerTest {
+
+    @Mock
+    private MethodArgumentNotValidException methodArgumentNotValidException;
+
+    @InjectMocks
+    private CustomExceptionHandler customExceptionHandler;
+
+    @Test
+    public void shouldManageAuthenticationException() {
+
+        final OAuth2AuthenticationException oAuth2AuthenticationException = new OAuth2AuthenticationException(new OAuth2Error("someCode"), "someMessage");
+
+        final ResponseEntity<ErrorVo> result = customExceptionHandler.exception(oAuth2AuthenticationException);
+        Assertions.assertThat(result.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
+        Assertions.assertThat(result.getBody().getMessage()).isEqualTo(oAuth2AuthenticationException.getMessage());
+        Assertions.assertThat(result.getBody().getTimestamp()).isEqualToIgnoringSeconds(ZonedDateTime.now());
+    }
+
+    @Test
+    public void shouldManageMethodArgumentNotValidException() {
+
+        final String message = "error message";
+        Mockito.when(methodArgumentNotValidException.getMessage()).thenReturn(message);
+
+        final ResponseEntity<ErrorVo> result = customExceptionHandler.exception(methodArgumentNotValidException);
+        Assertions.assertThat(result.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+        Assertions.assertThat(result.getBody().getMessage()).isEqualTo(message);
+        Assertions.assertThat(result.getBody().getTimestamp()).isEqualToIgnoringSeconds(ZonedDateTime.now());
+    }
+
+
+    @Test
+    public void shouldManageEveryOtherException() {
+
+        final Exception exception = new Exception("someMessage");
+
+        final ResponseEntity<ErrorVo> result = customExceptionHandler.exception(exception);
+        Assertions.assertThat(result.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
+        Assertions.assertThat(result.getBody().getMessage()).isEqualTo(exception.getMessage());
+        Assertions.assertThat(result.getBody().getTimestamp()).isEqualToIgnoringSeconds(ZonedDateTime.now());
+    }
+
+}
\ No newline at end of file
diff --git a/analytics-server/src/test/java/fr/gouv/tac/analytics/server/controller/mapper/AnalyticsMongoMapperTest.java b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/controller/mapper/AnalyticsMongoMapperTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..9ab7eda0a91ca8373d419bf29f4418eaf5c4cd22
--- /dev/null
+++ b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/controller/mapper/AnalyticsMongoMapperTest.java
@@ -0,0 +1,126 @@
+package fr.gouv.tac.analytics.server.controller.mapper;
+
+import fr.gouv.tac.analytics.server.controller.vo.AnalyticsVo;
+import fr.gouv.tac.analytics.server.controller.vo.TimestampedEventVo;
+import fr.gouv.tac.analytics.server.model.kafka.Analytics;
+import org.assertj.core.api.Assertions;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.springframework.test.context.junit.jupiter.SpringExtension;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.time.ZonedDateTime;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+@ExtendWith(SpringExtension.class)
+public class AnalyticsMongoMapperTest {
+
+    private static final AnalyticsMapper mapper = new AnalyticsMapperImpl();
+
+    @BeforeAll
+    public static void setUp() {
+        final TimestampedEventMapper timestampedEventMapper = new TimestampedEventMapperImpl();
+        ReflectionTestUtils.setField(mapper, "timestampedEventMapper", timestampedEventMapper);
+    }
+
+    @Test
+    public void shouldFailWhenAnalyticsIsNull() {
+
+        // Given
+        final AnalyticsVo analyticsVo = null;
+
+        // When
+        final Optional<Analytics> result = mapper.map(analyticsVo);
+
+        // Then
+        Assertions.assertThat(result).isEmpty();
+    }
+
+    @Test
+    public void shouldSucceedWhenEventsIsNull() {
+
+        // Given
+        final AnalyticsVo analyticsVo = getAnalytics();
+        analyticsVo.setEvents(null);
+
+        // When
+        final Optional<Analytics> result = mapper.map(analyticsVo);
+
+        // Then
+        Assertions.assertThat(result).isPresent();
+
+        Assertions.assertThat(result.get().getCreationDate()).isEqualToIgnoringSeconds(ZonedDateTime.now());
+
+        Assertions.assertThat(result.get().getInstallationUuid()).isEqualTo(analyticsVo.getInstallationUuid());
+        Assertions.assertThat(result.get().getInfos()).containsExactlyInAnyOrderEntriesOf(analyticsVo.getInfos());
+        Assertions.assertThat(result.get().getEvents()).isNull();
+        Assertions.assertThat(result.get().getErrors()).hasSameSizeAs(analyticsVo.getErrors());
+
+    }
+
+    @Test
+    public void shouldSucceedWhenErrorsIsNull() {
+
+        // Given
+        final AnalyticsVo analyticsVo = getAnalytics();
+        analyticsVo.setErrors(null);
+
+        // When
+        final Optional<Analytics> result = mapper.map(analyticsVo);
+
+        // Then
+        Assertions.assertThat(result).isPresent();
+
+        Assertions.assertThat(result.get().getCreationDate()).isEqualToIgnoringSeconds(ZonedDateTime.now());
+
+        Assertions.assertThat(result.get().getInstallationUuid()).isEqualTo(analyticsVo.getInstallationUuid());
+        Assertions.assertThat(result.get().getInfos()).containsExactlyInAnyOrderEntriesOf(analyticsVo.getInfos());
+        Assertions.assertThat(result.get().getEvents()).hasSameSizeAs(analyticsVo.getEvents());
+        Assertions.assertThat(result.get().getErrors()).isNull();
+
+    }
+
+
+    @Test
+    public void shouldSucceedWhenAnalyticsIsValid() {
+
+        // Given
+        final AnalyticsVo analyticsVo = getAnalytics();
+
+        // When
+        final Optional<Analytics> result = mapper.map(analyticsVo);
+
+        // Then
+        Assertions.assertThat(result).isPresent();
+
+        Assertions.assertThat(result.get().getCreationDate()).isEqualToIgnoringSeconds(ZonedDateTime.now());
+
+        Assertions.assertThat(result.get().getInstallationUuid()).isEqualTo(analyticsVo.getInstallationUuid());
+        Assertions.assertThat(result.get().getInfos()).containsExactlyInAnyOrderEntriesOf(analyticsVo.getInfos());
+        Assertions.assertThat(result.get().getEvents()).hasSameSizeAs(analyticsVo.getEvents());
+        Assertions.assertThat(result.get().getErrors()).hasSameSizeAs(analyticsVo.getErrors());
+
+    }
+
+    public AnalyticsVo getAnalytics() {
+        final Map<String, String> infos = Map.of("info1", "info1Value", "info2", "info2value");
+
+        final List<TimestampedEventVo> analyticsEvents = new ArrayList<>();
+        final List<TimestampedEventVo> analyticsErrors = new ArrayList<>();
+
+        final TimestampedEventVo event = TimestampedEventVo.builder().name("userAcceptedNotificationsInOnboarding").timestamp(ZonedDateTime.now()).description("some description").build();
+        final TimestampedEventVo error = TimestampedEventVo.builder().name("ERR432").timestamp(ZonedDateTime.now()).build();
+        analyticsEvents.add(event);
+        analyticsErrors.add(error);
+        return AnalyticsVo.builder()
+                .infos(infos)
+                .events(analyticsEvents)
+                .errors(analyticsErrors)
+                .build();
+
+    }
+}
diff --git a/analytics-server/src/test/java/fr/gouv/tac/analytics/server/controller/mapper/TimestampedEventMapperTest.java b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/controller/mapper/TimestampedEventMapperTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..faf9d07670dcee119a231521a1b9eb9230c89f16
--- /dev/null
+++ b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/controller/mapper/TimestampedEventMapperTest.java
@@ -0,0 +1,42 @@
+package fr.gouv.tac.analytics.server.controller.mapper;
+
+import fr.gouv.tac.analytics.server.controller.vo.TimestampedEventVo;
+import fr.gouv.tac.analytics.server.model.kafka.TimestampedEvent;
+import org.assertj.core.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.time.ZonedDateTime;
+
+public class TimestampedEventMapperTest {
+
+    private final TimestampedEventMapper timestampedEventMapper = new TimestampedEventMapperImpl();
+
+    @Test
+    public void shouldMapWithDescription() {
+        final TimestampedEventVo timestampedEventVo = TimestampedEventVo.builder()
+                .name("some fancy name")
+                .timestamp(ZonedDateTime.now())
+                .description("some description")
+                .build();
+
+        final TimestampedEvent result = timestampedEventMapper.map(timestampedEventVo);
+
+        Assertions.assertThat(result.getName()).isEqualTo(timestampedEventVo.getName());
+        Assertions.assertThat(result.getTimestamp()).isEqualTo(timestampedEventVo.getTimestamp());
+        Assertions.assertThat(result.getDescription()).isEqualTo(timestampedEventVo.getDescription());
+    }
+
+    @Test
+    public void shouldMapWithoutDescription() {
+        final TimestampedEventVo timestampedEventVo = TimestampedEventVo.builder()
+                .name("some fancy name")
+                .timestamp(ZonedDateTime.now())
+                .build();
+
+        final TimestampedEvent result = timestampedEventMapper.map(timestampedEventVo);
+
+        Assertions.assertThat(result.getName()).isEqualTo(timestampedEventVo.getName());
+        Assertions.assertThat(result.getTimestamp()).isEqualTo(timestampedEventVo.getTimestamp());
+        Assertions.assertThat(result.getDescription()).isNull();
+    }
+}
\ No newline at end of file
diff --git a/analytics-server/src/test/java/fr/gouv/tac/analytics/server/it/AnalyticsCreationOauth2ErrorTest.java b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/it/AnalyticsCreationOauth2ErrorTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..89a176c2c50fa6c3602cae5eafce1432f21561f7
--- /dev/null
+++ b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/it/AnalyticsCreationOauth2ErrorTest.java
@@ -0,0 +1,217 @@
+package fr.gouv.tac.analytics.server.it;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.hamcrest.Matchers.emptyString;
+import static org.hamcrest.Matchers.is;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import java.security.NoSuchAlgorithmException;
+import java.security.spec.InvalidKeySpecException;
+import java.time.ZonedDateTime;
+import java.util.UUID;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.kafka.core.KafkaTemplate;
+import org.springframework.kafka.support.SendResult;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.MvcResult;
+import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
+import org.springframework.util.concurrent.ListenableFuture;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import fr.gouv.tac.analytics.server.AnalyticsServerApplication;
+import fr.gouv.tac.analytics.server.config.security.oauth2tokenvalidator.ExpirationTokenPresenceOAuth2TokenValidator;
+import fr.gouv.tac.analytics.server.config.security.oauth2tokenvalidator.JtiCanOnlyBeUsedOnceOAuth2TokenValidator;
+import fr.gouv.tac.analytics.server.config.security.oauth2tokenvalidator.JtiPresenceOAuth2TokenValidator;
+import fr.gouv.tac.analytics.server.controller.vo.AnalyticsVo;
+import fr.gouv.tac.analytics.server.controller.vo.ErrorVo;
+import fr.gouv.tac.analytics.server.model.kafka.Analytics;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+
+@ActiveProfiles(value = "test")
+@SpringBootTest(classes = AnalyticsServerApplication.class)
+@AutoConfigureMockMvc
+public class AnalyticsCreationOauth2ErrorTest {
+
+    @MockBean
+    private KafkaTemplate<String, Analytics> kafkaTemplate;
+
+    @Mock
+    private ListenableFuture<SendResult<String, Analytics>> listenableFutureMock;
+
+    @Autowired
+    private MockMvc mockMvc;
+
+    @Autowired
+    private ObjectMapper objectMapper;
+
+    @Value("${analyticsserver.controller.analytics.path}")
+    private String analyticsControllerPath;
+
+    @Value("${analyticsserver.robert_jwt_analyticsprivatekey}")
+    private String jwtPrivateKey;
+
+    private JwtTokenHelper jwtTokenHelper;
+
+    @BeforeEach
+    public void setUp() throws InvalidKeySpecException, NoSuchAlgorithmException {
+        jwtTokenHelper = new JwtTokenHelper(jwtPrivateKey);
+    }
+
+
+    @Test
+    public void itShouldRejectWhenThereIsNoAuthenticationHeader() throws Exception {
+
+        final AnalyticsVo analyticsVo = buildAnalyticsVo();
+
+        final String analyticsAsJson = objectMapper.writeValueAsString(analyticsVo);
+
+        final MvcResult mvcResult = mockMvc.perform(MockMvcRequestBuilders.post(analyticsControllerPath)
+                .contentType(MediaType.APPLICATION_JSON)
+                .content(analyticsAsJson))
+                .andExpect(status().isUnauthorized())
+                .andReturn();
+
+        final ErrorVo errorVo = objectMapper.readValue(mvcResult.getResponse().getContentAsString(), ErrorVo.class);
+        assertThat(errorVo.getMessage()).isEqualTo("Full authentication is required to access this resource");
+        assertThat(errorVo.getTimestamp()).isEqualToIgnoringSeconds(ZonedDateTime.now());
+
+        verify(kafkaTemplate, never()).sendDefault(any(Analytics.class));
+    }
+
+    @Test
+    public void itShouldRejectTokenWithoutJTI() throws Exception {
+        final AnalyticsVo analyticsVo = buildAnalyticsVo();
+        final String analyticsAsJson = objectMapper.writeValueAsString(analyticsVo);
+
+        jwtTokenHelper.withIssueTime(ZonedDateTime.now());
+        jwtTokenHelper.withExpirationDate(ZonedDateTime.now().plusMinutes(5));
+        final String authorizationHeader = jwtTokenHelper.generateAuthorizationHeader();
+
+        final MvcResult mvcResult = mockMvc.perform(MockMvcRequestBuilders.post(analyticsControllerPath)
+                .header(HttpHeaders.AUTHORIZATION, authorizationHeader)
+                .contentType(MediaType.APPLICATION_JSON)
+                .content(analyticsAsJson))
+                .andExpect(status().isUnauthorized())
+                .andReturn();
+
+        final ErrorVo errorVo = objectMapper.readValue(mvcResult.getResponse().getContentAsString(), ErrorVo.class);
+        assertThat(errorVo.getMessage()).contains(JtiPresenceOAuth2TokenValidator.ERR_MESSAGE);
+        assertThat(errorVo.getTimestamp()).isEqualToIgnoringSeconds(ZonedDateTime.now());
+
+        verify(kafkaTemplate, never()).sendDefault(any(Analytics.class));
+
+    }
+
+    @Test
+    public void itShouldRejectTokenWithAnAlreadyUsedJTI() throws Exception {
+        final AnalyticsVo analyticsVo = buildAnalyticsVo();
+        final String analyticsAsJson = objectMapper.writeValueAsString(analyticsVo);
+
+        final String jti = UUID.randomUUID().toString();
+        jwtTokenHelper.withJti(jti);
+        jwtTokenHelper.withIssueTime(ZonedDateTime.now());
+        jwtTokenHelper.withExpirationDate(ZonedDateTime.now().plusMinutes(10));
+        final String authorizationHeader1 = jwtTokenHelper.generateAuthorizationHeader();
+
+        when(kafkaTemplate.sendDefault(any(Analytics.class))).thenReturn(listenableFutureMock);
+
+        mockMvc.perform(MockMvcRequestBuilders.post(analyticsControllerPath)
+                .header(HttpHeaders.AUTHORIZATION, authorizationHeader1)
+                .contentType(MediaType.APPLICATION_JSON)
+                .content(analyticsAsJson))
+                .andExpect(status().isOk())
+                .andExpect(content().string(is(emptyString())));
+
+        verify(kafkaTemplate, times(1)).sendDefault(any(Analytics.class));
+
+        jwtTokenHelper.withIssueTime(ZonedDateTime.now());
+        final String authorizationHeader2 = jwtTokenHelper.generateAuthorizationHeader();
+
+
+        final MvcResult mvcResult = mockMvc.perform(MockMvcRequestBuilders.post(analyticsControllerPath)
+                .header(HttpHeaders.AUTHORIZATION, authorizationHeader2)
+                .contentType(MediaType.APPLICATION_JSON)
+                .content(analyticsAsJson))
+                .andExpect(status().isUnauthorized())
+                .andReturn();
+
+        final ErrorVo errorVo = objectMapper.readValue(mvcResult.getResponse().getContentAsString(), ErrorVo.class);
+        assertThat(errorVo.getMessage()).contains(JtiCanOnlyBeUsedOnceOAuth2TokenValidator.ERR_MESSAGE);
+        assertThat(errorVo.getTimestamp()).isEqualToIgnoringSeconds(ZonedDateTime.now());
+
+
+        verify(kafkaTemplate, times(1)).sendDefault(any(Analytics.class));
+
+    }
+
+    @Test
+    public void itShouldRejectTokenWithoutTokenExpiration() throws Exception {
+        final AnalyticsVo analyticsVo = buildAnalyticsVo();
+        final String analyticsAsJson = objectMapper.writeValueAsString(analyticsVo);
+
+        jwtTokenHelper.withJti(UUID.randomUUID().toString());
+        jwtTokenHelper.withIssueTime(ZonedDateTime.now().minusMinutes(10));
+
+        final String authorizationHeader = jwtTokenHelper.generateAuthorizationHeader();
+
+
+        final MvcResult mvcResult = mockMvc.perform(MockMvcRequestBuilders.post(analyticsControllerPath)
+                .header(HttpHeaders.AUTHORIZATION, authorizationHeader)
+                .contentType(MediaType.APPLICATION_JSON)
+                .content(analyticsAsJson))
+                .andExpect(status().isUnauthorized())
+                .andReturn();
+
+        final ErrorVo errorVo = objectMapper.readValue(mvcResult.getResponse().getContentAsString(), ErrorVo.class);
+        assertThat(errorVo.getMessage()).contains(ExpirationTokenPresenceOAuth2TokenValidator.ERR_MESSAGE);
+        assertThat(errorVo.getTimestamp()).isEqualToIgnoringSeconds(ZonedDateTime.now());
+
+        verify(kafkaTemplate, never()).sendDefault(any(Analytics.class));
+    }
+
+
+    @Test
+    public void itShouldRejectExpiredToken() throws Exception {
+        final AnalyticsVo analyticsVo = buildAnalyticsVo();
+        final String analyticsAsJson = objectMapper.writeValueAsString(analyticsVo);
+
+        jwtTokenHelper.withJti(UUID.randomUUID().toString());
+        jwtTokenHelper.withIssueTime(ZonedDateTime.now().minusMinutes(10));
+        jwtTokenHelper.withExpirationDate(ZonedDateTime.now().minusMinutes(2));
+        final String authorizationHeader = jwtTokenHelper.generateAuthorizationHeader();
+
+
+        final MvcResult mvcResult = mockMvc.perform(MockMvcRequestBuilders.post(analyticsControllerPath)
+                .header(HttpHeaders.AUTHORIZATION, authorizationHeader)
+                .contentType(MediaType.APPLICATION_JSON)
+                .content(analyticsAsJson))
+                .andExpect(status().isUnauthorized())
+                .andReturn();
+
+        final ErrorVo errorVo = objectMapper.readValue(mvcResult.getResponse().getContentAsString(), ErrorVo.class);
+        assertThat(errorVo.getMessage()).startsWith("An error occurred while attempting to decode the Jwt: Jwt expired at");
+        assertThat(errorVo.getTimestamp()).isEqualToIgnoringSeconds(ZonedDateTime.now());
+
+        verify(kafkaTemplate, never()).sendDefault(any(Analytics.class));
+    }
+
+    private AnalyticsVo buildAnalyticsVo() {
+        return AnalyticsVo.builder()
+                .installationUuid("some installation uuid")
+                .build();
+    }
+
+}
diff --git a/analytics-server/src/test/java/fr/gouv/tac/analytics/server/it/AnalyticsCreationOauth2NominalTest.java b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/it/AnalyticsCreationOauth2NominalTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..431bf479d5e5bc94a37f727ca04fc0049c711ea8
--- /dev/null
+++ b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/it/AnalyticsCreationOauth2NominalTest.java
@@ -0,0 +1,148 @@
+package fr.gouv.tac.analytics.server.it;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import fr.gouv.tac.analytics.server.AnalyticsServerApplication;
+import fr.gouv.tac.analytics.server.controller.vo.AnalyticsVo;
+import fr.gouv.tac.analytics.server.model.kafka.Analytics;
+import org.apache.kafka.common.serialization.StringDeserializer;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.autoconfigure.kafka.KafkaProperties;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.kafka.core.DefaultKafkaConsumerFactory;
+import org.springframework.kafka.listener.ContainerProperties;
+import org.springframework.kafka.listener.KafkaMessageListenerContainer;
+import org.springframework.kafka.listener.MessageListener;
+import org.springframework.kafka.support.serializer.JsonDeserializer;
+import org.springframework.kafka.test.EmbeddedKafkaBroker;
+import org.springframework.kafka.test.context.EmbeddedKafka;
+import org.springframework.kafka.test.utils.ContainerTestUtils;
+import org.springframework.kafka.test.utils.KafkaTestUtils;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
+
+import java.security.NoSuchAlgorithmException;
+import java.security.spec.InvalidKeySpecException;
+import java.time.ZonedDateTime;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.awaitility.Awaitility.await;
+import static org.hamcrest.Matchers.emptyString;
+import static org.hamcrest.Matchers.is;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@ActiveProfiles(value = "test")
+@SpringBootTest(classes = AnalyticsServerApplication.class)
+@AutoConfigureMockMvc
+@EmbeddedKafka(partitions = 1, brokerProperties = {"listeners=PLAINTEXT://localhost:9094", "port=9094"}, topics = "topicNameForTest")
+public class AnalyticsCreationOauth2NominalTest {
+
+    private static final int QUEUE_READ_TIMEOUT = 2;
+
+    @Autowired
+    private MockMvc mockMvc;
+
+    @Autowired
+    private ObjectMapper objectMapper;
+
+    @Autowired
+    private EmbeddedKafkaBroker embeddedKafkaBroker;
+
+    @Autowired
+    private KafkaProperties kafkaProperties;
+
+    @Value("${analyticsserver.controller.analytics.path}")
+    private String analyticsControllerPath;
+
+    @Value("${analyticsserver.robert_jwt_analyticsprivatekey}")
+    private String jwtPrivateKey;
+
+    private JwtTokenHelper jwtTokenHelper;
+
+    private KafkaMessageListenerContainer<String, Analytics> container;
+
+    private final List<Analytics> records = new ArrayList<>();
+
+    @BeforeEach
+    public void setUp() throws InvalidKeySpecException, NoSuchAlgorithmException {
+        records.clear();
+        jwtTokenHelper = new JwtTokenHelper(jwtPrivateKey);
+        final Map<String, Object> consumerProps = KafkaTestUtils.consumerProps(kafkaProperties.getConsumer().getGroupId(), "false", embeddedKafkaBroker);
+        final DefaultKafkaConsumerFactory<String, Analytics> defaultKafkaConsumerFactory = new DefaultKafkaConsumerFactory<>(consumerProps, new StringDeserializer(), new JsonDeserializer<>(Analytics.class, objectMapper));
+        final ContainerProperties containerProperties = new ContainerProperties(kafkaProperties.getTemplate().getDefaultTopic());
+        container = new KafkaMessageListenerContainer<>(defaultKafkaConsumerFactory, containerProperties);
+        container.setupMessageListener((MessageListener<String, Analytics>) message -> records.add(message.value()));
+        container.start();
+        ContainerTestUtils.waitForAssignment(container, embeddedKafkaBroker.getPartitionsPerTopic());
+    }
+
+    @AfterEach
+    public void tearDown() {
+        container.stop();
+    }
+
+
+    @Test
+    public void itShouldAcceptValidToken() throws Exception {
+        final AnalyticsVo analyticsVo = buildAnalyticsVo();
+        final String analyticsAsJson = objectMapper.writeValueAsString(analyticsVo);
+
+        jwtTokenHelper.withIssueTime(ZonedDateTime.now());
+        jwtTokenHelper.withExpirationDate(ZonedDateTime.now().plusMinutes(5));
+        jwtTokenHelper.withJti(UUID.randomUUID().toString());
+        final String authorizationHeader = jwtTokenHelper.generateAuthorizationHeader();
+
+        mockMvc.perform(MockMvcRequestBuilders.post(analyticsControllerPath)
+                .header(HttpHeaders.AUTHORIZATION, authorizationHeader)
+                .contentType(MediaType.APPLICATION_JSON)
+                .content(analyticsAsJson))
+                .andExpect(status().isOk())
+                .andExpect(content().string(is(emptyString())));
+
+        await().atMost(QUEUE_READ_TIMEOUT, SECONDS).untilAsserted(() -> assertThat(records).hasSize(1));
+    }
+
+
+    @Test
+    public void itShouldAcceptNonExpiredToken() throws Exception {
+        final AnalyticsVo analyticsVo = buildAnalyticsVo();
+        final String analyticsAsJson = objectMapper.writeValueAsString(analyticsVo);
+
+        jwtTokenHelper.withJti(UUID.randomUUID().toString());
+        jwtTokenHelper.withIssueTime(ZonedDateTime.now().minusMinutes(10));
+        jwtTokenHelper.withExpirationDate(ZonedDateTime.now().plusMinutes(2));
+        final String authorizationHeader = jwtTokenHelper.generateAuthorizationHeader();
+
+
+        mockMvc.perform(MockMvcRequestBuilders.post(analyticsControllerPath)
+                .header(HttpHeaders.AUTHORIZATION, authorizationHeader)
+                .contentType(MediaType.APPLICATION_JSON)
+                .content(analyticsAsJson))
+                .andExpect(status().isOk())
+                .andExpect(content().string(is(emptyString())));
+
+        await().atMost(QUEUE_READ_TIMEOUT, SECONDS).untilAsserted(() -> assertThat(records).hasSize(1));
+
+    }
+
+
+    private AnalyticsVo buildAnalyticsVo() {
+        return AnalyticsVo.builder()
+                .installationUuid("some installation uuid")
+                .build();
+    }
+
+}
diff --git a/analytics-server/src/test/java/fr/gouv/tac/analytics/server/it/AnalyticsCreationTest.java b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/it/AnalyticsCreationTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..e1e873abb2927eb432d7ad07c19ea1ac18e90aff
--- /dev/null
+++ b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/it/AnalyticsCreationTest.java
@@ -0,0 +1,153 @@
+package fr.gouv.tac.analytics.server.it;
+
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.awaitility.Awaitility.await;
+import static org.hamcrest.Matchers.emptyString;
+import static org.hamcrest.Matchers.is;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import java.time.ZonedDateTime;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.autoconfigure.kafka.KafkaProperties;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.http.MediaType;
+import org.springframework.kafka.core.DefaultKafkaConsumerFactory;
+import org.springframework.kafka.listener.ContainerProperties;
+import org.springframework.kafka.listener.KafkaMessageListenerContainer;
+import org.springframework.kafka.listener.MessageListener;
+import org.springframework.kafka.support.serializer.JsonDeserializer;
+import org.springframework.kafka.test.EmbeddedKafkaBroker;
+import org.springframework.kafka.test.context.EmbeddedKafka;
+import org.springframework.kafka.test.utils.ContainerTestUtils;
+import org.springframework.kafka.test.utils.KafkaTestUtils;
+import org.springframework.security.test.context.support.WithMockUser;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import fr.gouv.tac.analytics.server.AnalyticsServerApplication;
+import fr.gouv.tac.analytics.server.controller.vo.AnalyticsVo;
+import fr.gouv.tac.analytics.server.controller.vo.TimestampedEventVo;
+import fr.gouv.tac.analytics.server.model.kafka.Analytics;
+import fr.gouv.tac.analytics.server.model.kafka.TimestampedEvent;
+import fr.gouv.tac.analytics.server.utils.TestUtils;
+import org.apache.kafka.common.serialization.StringDeserializer;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+@ActiveProfiles(value = "test")
+@SpringBootTest(classes = AnalyticsServerApplication.class)
+@AutoConfigureMockMvc
+@EmbeddedKafka(partitions = 1, brokerProperties = {"listeners=PLAINTEXT://localhost:9094", "port=9094"}, topics = "topicNameForTest")
+public class AnalyticsCreationTest {
+
+    private static final int QUEUE_READ_TIMEOUT = 2;
+
+    @Autowired
+    private MockMvc mockMvc;
+
+    @Autowired
+    private ObjectMapper objectMapper;
+
+    @Autowired
+    private EmbeddedKafkaBroker embeddedKafkaBroker;
+
+    @Autowired
+    private KafkaProperties kafkaProperties;
+
+    @Value("${analyticsserver.controller.analytics.path}")
+    private String analyticsControllerPath;
+
+    private KafkaMessageListenerContainer<String, Analytics> container;
+
+    private final List<Analytics> records = new ArrayList<>();
+
+    @BeforeEach
+    public void setUp() {
+        records.clear();
+        final Map<String, Object> consumerProps = KafkaTestUtils.consumerProps(kafkaProperties.getConsumer().getGroupId(), "false", embeddedKafkaBroker);
+        final DefaultKafkaConsumerFactory<String, Analytics> defaultKafkaConsumerFactory = new DefaultKafkaConsumerFactory<>(consumerProps, new StringDeserializer(), new JsonDeserializer<>(Analytics.class, objectMapper));
+        final ContainerProperties containerProperties = new ContainerProperties(kafkaProperties.getTemplate().getDefaultTopic());
+        container = new KafkaMessageListenerContainer<>(defaultKafkaConsumerFactory, containerProperties);
+        container.setupMessageListener((MessageListener<String, Analytics>) message -> records.add(message.value()));
+        container.start();
+        ContainerTestUtils.waitForAssignment(container, embeddedKafkaBroker.getPartitionsPerTopic());
+    }
+
+    @AfterEach
+    public void tearDown() {
+        container.stop();
+    }
+
+    @Test
+    @WithMockUser
+    public void itShouldStoreValidAnalytics() throws Exception {
+        final AnalyticsVo analyticsVo = buildAnalyticsVo();
+
+        final List<TimestampedEvent> expectedEvents = analyticsVo.getEvents().stream()
+                .map(TestUtils::convertTimestampedEvent)
+                .collect(Collectors.toList());
+
+        final List<TimestampedEvent> expectedErrors = analyticsVo.getErrors().stream()
+                .map(TestUtils::convertTimestampedEvent)
+                .collect(Collectors.toList());
+
+        final String analyticsAsJson = objectMapper.writeValueAsString(analyticsVo);
+
+        // WHEN
+        mockMvc.perform(MockMvcRequestBuilders.post(analyticsControllerPath)
+                .contentType(MediaType.APPLICATION_JSON)
+                .content(analyticsAsJson))
+                .andExpect(status().isOk())
+                .andExpect(content().string(is(emptyString())));
+
+
+        await().atMost(QUEUE_READ_TIMEOUT, SECONDS).untilAsserted(() -> assertThat(records).isNotEmpty());
+
+        assertThat(records).hasSize(1);
+        final Analytics analyticsResult = records.get(0);
+
+        assertThat(analyticsResult.getCreationDate()).isEqualToIgnoringSeconds(ZonedDateTime.now());
+
+        assertThat(analyticsResult.getInstallationUuid()).isEqualTo(analyticsVo.getInstallationUuid());
+        assertThat(analyticsResult.getInfos()).containsExactlyInAnyOrderEntriesOf(analyticsVo.getInfos());
+        assertThat(analyticsResult.getEvents()).containsExactlyInAnyOrderElementsOf(expectedEvents);
+        assertThat(analyticsResult.getErrors()).containsExactlyInAnyOrderElementsOf(expectedErrors);
+
+    }
+
+
+    private AnalyticsVo buildAnalyticsVo() {
+        final Map<String, String> infos = Map.of("info1", "info1Value", "info2", "info2value");
+
+        final ZonedDateTime timestamp = ZonedDateTime.parse("2020-12-17T10:59:17.123Z[UTC]");
+
+        final TimestampedEventVo event1 = TimestampedEventVo.builder().name("eventName1").timestamp(timestamp).description("event1 description").build();
+        final TimestampedEventVo event2 = TimestampedEventVo.builder().name("eventName2").timestamp(timestamp).build();
+
+        final TimestampedEventVo error1 = TimestampedEventVo.builder().name("errorName1").timestamp(timestamp).build();
+        final TimestampedEventVo error2 = TimestampedEventVo.builder().name("errorName2").timestamp(timestamp).description("error2 description").build();
+
+        return AnalyticsVo.builder()
+                .installationUuid("some installation uuid")
+                .infos(infos)
+                .events(Arrays.asList(event1, event2))
+                .errors(Arrays.asList(error1, error2))
+                .build();
+    }
+
+
+}
+
diff --git a/analytics-server/src/test/java/fr/gouv/tac/analytics/server/it/AnalyticsCreationValidationTest.java b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/it/AnalyticsCreationValidationTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..e9d71bd3d4df85333c9eb334dcebe17604131d0a
--- /dev/null
+++ b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/it/AnalyticsCreationValidationTest.java
@@ -0,0 +1,425 @@
+package fr.gouv.tac.analytics.server.it;
+
+import static fr.gouv.tac.analytics.server.config.validation.validator.AnalyticsVoInfoSizeValidator.*;
+import static fr.gouv.tac.analytics.server.config.validation.validator.TimestampedEventCollectionValidator.DESCRIPTION_TOO_LONG_ERROR_MESSAGE;
+import static fr.gouv.tac.analytics.server.config.validation.validator.TimestampedEventCollectionValidator.NAME_TOO_LONG_ERROR_MESSAGE;
+import static org.hamcrest.Matchers.emptyString;
+import static org.hamcrest.Matchers.is;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import java.time.ZonedDateTime;
+import java.util.Arrays;
+import java.util.Map;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.http.MediaType;
+import org.springframework.kafka.core.KafkaTemplate;
+import org.springframework.kafka.support.SendResult;
+import org.springframework.security.test.context.support.WithMockUser;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.MvcResult;
+import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
+import org.springframework.util.concurrent.ListenableFuture;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import fr.gouv.tac.analytics.server.AnalyticsServerApplication;
+import fr.gouv.tac.analytics.server.controller.vo.AnalyticsVo;
+import fr.gouv.tac.analytics.server.controller.vo.ErrorVo;
+import fr.gouv.tac.analytics.server.controller.vo.TimestampedEventVo;
+import fr.gouv.tac.analytics.server.model.kafka.Analytics;
+import org.apache.commons.lang3.RandomStringUtils;
+import org.assertj.core.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+
+@ActiveProfiles(value = "test")
+@SpringBootTest(classes = AnalyticsServerApplication.class)
+@AutoConfigureMockMvc
+public class AnalyticsCreationValidationTest {
+
+    @Autowired
+    private MockMvc mockMvc;
+
+    @Autowired
+    private ObjectMapper objectMapper;
+
+    @Value("${analyticsserver.controller.analytics.path}")
+    private String analyticsControllerPath;
+
+    @MockBean
+    private KafkaTemplate<String, Analytics> kafkaTemplate;
+
+    @Mock
+    private ListenableFuture<SendResult<String, Analytics>> listenableFutureMock;
+
+    /****************
+     * ROOT
+     ******/
+
+    @Test
+    @WithMockUser
+    public void itShouldAcceptValidAnalytics() throws Exception {
+
+        final AnalyticsVo analyticsVo = buildAnalyticsVo();
+        final String analyticsAsJson = objectMapper.writeValueAsString(analyticsVo);
+
+        when(kafkaTemplate.sendDefault(any(Analytics.class))).thenReturn(listenableFutureMock);
+
+        mockMvc.perform(MockMvcRequestBuilders.post(analyticsControllerPath)
+                .contentType(MediaType.APPLICATION_JSON)
+                .content(analyticsAsJson))
+                .andExpect(status().isOk())
+                .andExpect(content().string(is(emptyString())));
+    }
+
+    @Test
+    @WithMockUser
+    public void itShouldRejectAnalyticsWithoutInstallationUuid() throws Exception {
+
+        final AnalyticsVo analyticsVo = buildAnalyticsVo();
+        analyticsVo.setInstallationUuid(null);
+
+        final String analyticsAsJson = objectMapper.writeValueAsString(analyticsVo);
+
+        final MvcResult mvcResult = mockMvc.perform(MockMvcRequestBuilders.post(analyticsControllerPath)
+                .contentType(MediaType.APPLICATION_JSON)
+                .content(analyticsAsJson))
+                .andExpect(status().isBadRequest())
+                .andReturn();
+
+        final ErrorVo errorVo = objectMapper.readValue(mvcResult.getResponse().getContentAsString(), ErrorVo.class);
+        Assertions.assertThat(errorVo.getMessage()).contains("'analyticsVo' on field 'installationUuid': rejected value [null]");
+        Assertions.assertThat(errorVo.getTimestamp()).isEqualToIgnoringSeconds(ZonedDateTime.now());
+
+        verify(kafkaTemplate, never()).sendDefault(any(Analytics.class));
+    }
+
+    @Test
+    @WithMockUser
+    public void itShouldRejectAnalyticsWithEmptyInstallationUuid() throws Exception {
+
+        final AnalyticsVo analyticsVo = buildAnalyticsVo();
+        analyticsVo.setInstallationUuid("");
+
+        final String analyticsAsJson = objectMapper.writeValueAsString(analyticsVo);
+
+        final MvcResult mvcResult = mockMvc.perform(MockMvcRequestBuilders.post(analyticsControllerPath)
+                .contentType(MediaType.APPLICATION_JSON)
+                .content(analyticsAsJson))
+                .andExpect(status().isBadRequest())
+                .andReturn();
+
+        final ErrorVo errorVo = objectMapper.readValue(mvcResult.getResponse().getContentAsString(), ErrorVo.class);
+        Assertions.assertThat(errorVo.getMessage()).contains("'analyticsVo' on field 'installationUuid': rejected value []");
+        Assertions.assertThat(errorVo.getTimestamp()).isEqualToIgnoringSeconds(ZonedDateTime.now());
+
+        verify(kafkaTemplate, never()).sendDefault(any(Analytics.class));
+    }
+
+    @Test
+    @WithMockUser
+    public void itShouldRejectAnalyticsWithTooLongInstallationUuid() throws Exception {
+
+        final AnalyticsVo analyticsVo = buildAnalyticsVo();
+        analyticsVo.setInstallationUuid(RandomStringUtils.random(65));
+
+        final String analyticsAsJson = objectMapper.writeValueAsString(analyticsVo);
+
+        final MvcResult mvcResult = mockMvc.perform(MockMvcRequestBuilders.post(analyticsControllerPath)
+                .contentType(MediaType.APPLICATION_JSON)
+                .content(analyticsAsJson))
+                .andExpect(status().isBadRequest())
+                .andReturn();
+
+        final ErrorVo errorVo = objectMapper.readValue(mvcResult.getResponse().getContentAsString(), ErrorVo.class);
+        Assertions.assertThat(errorVo.getMessage()).contains("Field error in object 'analyticsVo' on field 'installationUuid': rejected value");
+        Assertions.assertThat(errorVo.getTimestamp()).isEqualToIgnoringSeconds(ZonedDateTime.now());
+
+        verify(kafkaTemplate, never()).sendDefault(any(Analytics.class));
+    }
+
+    /****************
+     * INFO
+     ******/
+
+    @Test
+    @WithMockUser
+    public void itShouldRejectAnalyticsWithTooManyInfo() throws Exception {
+
+        final AnalyticsVo analyticsVo = buildAnalyticsVo();
+        analyticsVo.setInfos(Map.of("info1", "info1Value", "info2", "info2value", "info3", "info3value"));
+
+        final String analyticsAsJson = objectMapper.writeValueAsString(analyticsVo);
+
+        final MvcResult mvcResult = mockMvc.perform(MockMvcRequestBuilders.post(analyticsControllerPath)
+                .contentType(MediaType.APPLICATION_JSON)
+                .content(analyticsAsJson))
+                .andExpect(status().isBadRequest())
+                .andReturn();
+
+        final ErrorVo errorVo = objectMapper.readValue(mvcResult.getResponse().getContentAsString(), ErrorVo.class);
+        Assertions.assertThat(errorVo.getMessage()).contains(String.format(TOO_MANY_INFO_ERROR_MESSAGE, 3, 2));
+        Assertions.assertThat(errorVo.getTimestamp()).isEqualToIgnoringSeconds(ZonedDateTime.now());
+
+        verify(kafkaTemplate, never()).sendDefault(any(Analytics.class));
+    }
+
+    @Test
+    @WithMockUser
+    public void itShouldRejectAnalyticsWithInfoKeyTooLong() throws Exception {
+
+        final AnalyticsVo analyticsVo = buildAnalyticsVo();
+        analyticsVo.setInfos(Map.of("abcdefghijkl", "info1Value"));
+
+        final String analyticsAsJson = objectMapper.writeValueAsString(analyticsVo);
+
+        final MvcResult mvcResult = mockMvc.perform(MockMvcRequestBuilders.post(analyticsControllerPath)
+                .contentType(MediaType.APPLICATION_JSON)
+                .content(analyticsAsJson))
+                .andExpect(status().isBadRequest())
+                .andReturn();
+
+        final ErrorVo errorVo = objectMapper.readValue(mvcResult.getResponse().getContentAsString(), ErrorVo.class);
+        Assertions.assertThat(errorVo.getMessage()).contains(String.format(KEY_TOO_LONG_ERROR_MESSAGE, 10, 12));
+        Assertions.assertThat(errorVo.getTimestamp()).isEqualToIgnoringSeconds(ZonedDateTime.now());
+
+        verify(kafkaTemplate, never()).sendDefault(any(Analytics.class));
+    }
+
+    @Test
+    @WithMockUser
+    public void itShouldRejectAnalyticsWithInfoValueTooLong() throws Exception {
+
+        final AnalyticsVo analyticsVo = buildAnalyticsVo();
+        analyticsVo.setInfos(Map.of("info1", "info1ValueTooLong"));
+
+        final String analyticsAsJson = objectMapper.writeValueAsString(analyticsVo);
+
+        final MvcResult mvcResult = mockMvc.perform(MockMvcRequestBuilders.post(analyticsControllerPath)
+                .contentType(MediaType.APPLICATION_JSON)
+                .content(analyticsAsJson))
+                .andExpect(status().isBadRequest())
+                .andReturn();
+
+        final ErrorVo errorVo = objectMapper.readValue(mvcResult.getResponse().getContentAsString(), ErrorVo.class);
+        Assertions.assertThat(errorVo.getMessage()).contains(String.format(VALUE_TOO_LONG_ERROR_MESSAGE, 12, 17));
+        Assertions.assertThat(errorVo.getTimestamp()).isEqualToIgnoringSeconds(ZonedDateTime.now());
+
+        verify(kafkaTemplate, never()).sendDefault(any(Analytics.class));
+    }
+
+    /****************
+     * EVENT
+     ******/
+
+    @Test
+    @WithMockUser
+    public void itShouldRejectAnalyticsWithEmptyEventName() throws Exception {
+
+        final AnalyticsVo analyticsVo = buildAnalyticsVo();
+        analyticsVo.getEvents().get(0).setName("");
+
+        final String analyticsAsJson = objectMapper.writeValueAsString(analyticsVo);
+
+        final MvcResult mvcResult = mockMvc.perform(MockMvcRequestBuilders.post(analyticsControllerPath)
+                .contentType(MediaType.APPLICATION_JSON)
+                .content(analyticsAsJson))
+                .andExpect(status().isBadRequest())
+                .andReturn();
+
+        final ErrorVo errorVo = objectMapper.readValue(mvcResult.getResponse().getContentAsString(), ErrorVo.class);
+        Assertions.assertThat(errorVo.getMessage()).contains("Field error in object 'analyticsVo' on field 'events[0].name': rejected value []");
+        Assertions.assertThat(errorVo.getTimestamp()).isEqualToIgnoringSeconds(ZonedDateTime.now());
+
+        verify(kafkaTemplate, never()).sendDefault(any(Analytics.class));
+    }
+
+    @Test
+    @WithMockUser
+    public void itShouldRejectAnalyticsWithEventNameTooLong() throws Exception {
+
+        final AnalyticsVo analyticsVo = buildAnalyticsVo();
+        analyticsVo.getEvents().get(0).setName("Even name too long");
+
+        final String analyticsAsJson = objectMapper.writeValueAsString(analyticsVo);
+
+        final MvcResult mvcResult = mockMvc.perform(MockMvcRequestBuilders.post(analyticsControllerPath)
+                .contentType(MediaType.APPLICATION_JSON)
+                .content(analyticsAsJson))
+                .andExpect(status().isBadRequest())
+                .andReturn();
+
+        final ErrorVo errorVo = objectMapper.readValue(mvcResult.getResponse().getContentAsString(), ErrorVo.class);
+        Assertions.assertThat(errorVo.getMessage()).contains(String.format(NAME_TOO_LONG_ERROR_MESSAGE, "EVENT", 10, 18));
+        Assertions.assertThat(errorVo.getTimestamp()).isEqualToIgnoringSeconds(ZonedDateTime.now());
+
+        verify(kafkaTemplate, never()).sendDefault(any(Analytics.class));
+    }
+
+    @Test
+    @WithMockUser
+    public void itShouldRejectAnalyticsWithoutEventTimeStamp() throws Exception {
+
+        final AnalyticsVo analyticsVo = buildAnalyticsVo();
+        analyticsVo.getEvents().get(0).setTimestamp(null);
+
+        final String analyticsAsJson = objectMapper.writeValueAsString(analyticsVo);
+
+        final MvcResult mvcResult = mockMvc.perform(MockMvcRequestBuilders.post(analyticsControllerPath)
+                .contentType(MediaType.APPLICATION_JSON)
+                .content(analyticsAsJson))
+                .andExpect(status().isBadRequest())
+                .andReturn();
+
+        final ErrorVo errorVo = objectMapper.readValue(mvcResult.getResponse().getContentAsString(), ErrorVo.class);
+        Assertions.assertThat(errorVo.getMessage()).contains("Field error in object 'analyticsVo' on field 'events[0].timestamp': rejected value [null]");
+        Assertions.assertThat(errorVo.getTimestamp()).isEqualToIgnoringSeconds(ZonedDateTime.now());
+
+        verify(kafkaTemplate, never()).sendDefault(any(Analytics.class));
+    }
+
+    @Test
+    @WithMockUser
+    public void itShouldRejectAnalyticsWithEvenDescriptionTooLong() throws Exception {
+
+        final AnalyticsVo analyticsVo = buildAnalyticsVo();
+        analyticsVo.getEvents().get(0).setDescription("Event description too long");
+
+        final String analyticsAsJson = objectMapper.writeValueAsString(analyticsVo);
+
+        final MvcResult mvcResult = mockMvc.perform(MockMvcRequestBuilders.post(analyticsControllerPath)
+                .contentType(MediaType.APPLICATION_JSON)
+                .content(analyticsAsJson))
+                .andExpect(status().isBadRequest())
+                .andReturn();
+
+        final ErrorVo errorVo = objectMapper.readValue(mvcResult.getResponse().getContentAsString(), ErrorVo.class);
+        Assertions.assertThat(errorVo.getMessage()).contains(String.format(DESCRIPTION_TOO_LONG_ERROR_MESSAGE, "EVENT", 20, 26));
+        Assertions.assertThat(errorVo.getTimestamp()).isEqualToIgnoringSeconds(ZonedDateTime.now());
+
+        verify(kafkaTemplate, never()).sendDefault(any(Analytics.class));
+    }
+
+    /****************
+     * ERROR
+     ******/
+
+
+    @Test
+    @WithMockUser
+    public void itShouldRejectAnalyticsWithEmptyErrorName() throws Exception {
+
+        final AnalyticsVo analyticsVo = buildAnalyticsVo();
+        analyticsVo.getErrors().get(0).setName("");
+
+        final String analyticsAsJson = objectMapper.writeValueAsString(analyticsVo);
+
+        final MvcResult mvcResult = mockMvc.perform(MockMvcRequestBuilders.post(analyticsControllerPath)
+                .contentType(MediaType.APPLICATION_JSON)
+                .content(analyticsAsJson))
+                .andExpect(status().isBadRequest())
+                .andReturn();
+
+        final ErrorVo errorVo = objectMapper.readValue(mvcResult.getResponse().getContentAsString(), ErrorVo.class);
+        Assertions.assertThat(errorVo.getMessage()).contains("Field error in object 'analyticsVo' on field 'errors[0].name': rejected value []");
+        Assertions.assertThat(errorVo.getTimestamp()).isEqualToIgnoringSeconds(ZonedDateTime.now());
+
+        verify(kafkaTemplate, never()).sendDefault(any(Analytics.class));
+    }
+
+    @Test
+    @WithMockUser
+    public void itShouldRejectAnalyticsWithErrorNameTooLong() throws Exception {
+
+        final AnalyticsVo analyticsVo = buildAnalyticsVo();
+        analyticsVo.getErrors().get(0).setName("Error name too long");
+
+        final String analyticsAsJson = objectMapper.writeValueAsString(analyticsVo);
+
+        final MvcResult mvcResult = mockMvc.perform(MockMvcRequestBuilders.post(analyticsControllerPath)
+                .contentType(MediaType.APPLICATION_JSON)
+                .content(analyticsAsJson))
+                .andExpect(status().isBadRequest())
+                .andReturn();
+
+        final ErrorVo errorVo = objectMapper.readValue(mvcResult.getResponse().getContentAsString(), ErrorVo.class);
+        Assertions.assertThat(errorVo.getMessage()).contains(String.format(NAME_TOO_LONG_ERROR_MESSAGE, "ERROR", 10, 19));
+        Assertions.assertThat(errorVo.getTimestamp()).isEqualToIgnoringSeconds(ZonedDateTime.now());
+
+        verify(kafkaTemplate, never()).sendDefault(any(Analytics.class));
+    }
+
+    @Test
+    @WithMockUser
+    public void itShouldRejectAnalyticsWithoutErrorTimeStamp() throws Exception {
+
+        final AnalyticsVo analyticsVo = buildAnalyticsVo();
+        analyticsVo.getErrors().get(0).setTimestamp(null);
+
+        final String analyticsAsJson = objectMapper.writeValueAsString(analyticsVo);
+
+        final MvcResult mvcResult = mockMvc.perform(MockMvcRequestBuilders.post(analyticsControllerPath)
+                .contentType(MediaType.APPLICATION_JSON)
+                .content(analyticsAsJson))
+                .andExpect(status().isBadRequest())
+                .andReturn();
+
+        final ErrorVo errorVo = objectMapper.readValue(mvcResult.getResponse().getContentAsString(), ErrorVo.class);
+        Assertions.assertThat(errorVo.getMessage()).contains("Field error in object 'analyticsVo' on field 'errors[0].timestamp': rejected value [null]");
+        Assertions.assertThat(errorVo.getTimestamp()).isEqualToIgnoringSeconds(ZonedDateTime.now());
+
+        verify(kafkaTemplate, never()).sendDefault(any(Analytics.class));
+    }
+
+    @Test
+    @WithMockUser
+    public void itShouldRejectAnalyticsWithErrorDescriptionTooLong() throws Exception {
+
+        final AnalyticsVo analyticsVo = buildAnalyticsVo();
+        analyticsVo.getErrors().get(0).setDescription("Error description too long");
+
+        final String analyticsAsJson = objectMapper.writeValueAsString(analyticsVo);
+
+        final MvcResult mvcResult = mockMvc.perform(MockMvcRequestBuilders.post(analyticsControllerPath)
+                .contentType(MediaType.APPLICATION_JSON)
+                .content(analyticsAsJson))
+                .andExpect(status().isBadRequest())
+                .andReturn();
+
+        final ErrorVo errorVo = objectMapper.readValue(mvcResult.getResponse().getContentAsString(), ErrorVo.class);
+        Assertions.assertThat(errorVo.getMessage()).contains(String.format(DESCRIPTION_TOO_LONG_ERROR_MESSAGE, "ERROR", 20, 26));
+        Assertions.assertThat(errorVo.getTimestamp()).isEqualToIgnoringSeconds(ZonedDateTime.now());
+
+        verify(kafkaTemplate, never()).sendDefault(any(Analytics.class));
+    }
+
+    private AnalyticsVo buildAnalyticsVo() {
+        final Map<String, String> infos = Map.of("info1", "info1Value", "info2", "info2value");
+
+        final ZonedDateTime timestamp = ZonedDateTime.parse("2020-12-17T10:59:17.123Z");
+
+        final TimestampedEventVo event1 = TimestampedEventVo.builder().name("eventName1").timestamp(timestamp).description("event1 description").build();
+        final TimestampedEventVo event2 = TimestampedEventVo.builder().name("eventName2").timestamp(timestamp).build();
+
+        final TimestampedEventVo error1 = TimestampedEventVo.builder().name("errorName1").timestamp(timestamp).build();
+        final TimestampedEventVo error2 = TimestampedEventVo.builder().name("errorName2").timestamp(timestamp).description("error2 description").build();
+
+        return AnalyticsVo.builder()
+                .installationUuid("some installation uuid")
+                .infos(infos)
+                .events(Arrays.asList(event1, event2))
+                .errors(Arrays.asList(error1, error2))
+                .build();
+    }
+
+
+}
+
diff --git a/analytics-server/src/test/java/fr/gouv/tac/analytics/server/it/JwtTokenHelper.java b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/it/JwtTokenHelper.java
new file mode 100644
index 0000000000000000000000000000000000000000..88b1daebf4f72860592b68e4f8b06e3563d2fbb9
--- /dev/null
+++ b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/it/JwtTokenHelper.java
@@ -0,0 +1,67 @@
+package fr.gouv.tac.analytics.server.it;
+
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.crypto.RSASSASigner;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.SignedJWT;
+import org.springframework.util.Base64Utils;
+
+import java.security.KeyFactory;
+import java.security.NoSuchAlgorithmException;
+import java.security.PrivateKey;
+import java.security.spec.InvalidKeySpecException;
+import java.security.spec.PKCS8EncodedKeySpec;
+import java.time.ZonedDateTime;
+import java.util.Date;
+
+public class JwtTokenHelper {
+
+    private final RSASSASigner rsassaSigner;
+    private final JWTClaimsSet.Builder builder;
+
+    public JwtTokenHelper(final String rsaPrivateKey) throws NoSuchAlgorithmException, InvalidKeySpecException {
+
+        final byte[] privateKeyAsByteArray = Base64Utils.decodeFromString(rsaPrivateKey);
+
+        final PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(privateKeyAsByteArray);
+        final KeyFactory kf = KeyFactory.getInstance("RSA");
+        final PrivateKey privateKey = kf.generatePrivate(keySpec);
+
+        rsassaSigner = new RSASSASigner(privateKey);
+        builder = new JWTClaimsSet.Builder();
+    }
+
+    public JwtTokenHelper withExpirationDate(final ZonedDateTime expirationDate) {
+        final Date expiration = Date.from(expirationDate.toInstant());
+        builder.expirationTime(expiration);
+        return this;
+    }
+
+    public JwtTokenHelper withIssueTime(final ZonedDateTime issueDate) {
+        final Date issue = Date.from(issueDate.toInstant());
+        builder.issueTime(issue);
+        return this;
+    }
+
+    public JwtTokenHelper withJti(final String jti) {
+        builder.jwtID(jti);
+        return this;
+    }
+
+    public String generateToken() throws JOSEException {
+
+        final JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.RS256).build();
+        final JWTClaimsSet body = this.builder.build();
+
+        final SignedJWT signedJWT = new SignedJWT(header, body);
+        signedJWT.sign(rsassaSigner);
+        return signedJWT.serialize();
+    }
+
+    public String generateAuthorizationHeader() throws JOSEException {
+        return "Bearer " + generateToken();
+    }
+
+}
diff --git a/analytics-server/src/test/java/fr/gouv/tac/analytics/server/repository/mongo/TokenIdentifierRepositoryTest.java b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/repository/mongo/TokenIdentifierRepositoryTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..764f7027865b71d014c2f42bfe25edec108846fb
--- /dev/null
+++ b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/repository/mongo/TokenIdentifierRepositoryTest.java
@@ -0,0 +1,90 @@
+package fr.gouv.tac.analytics.server.repository.mongo;
+
+import fr.gouv.tac.analytics.server.config.mongodb.MongoConfiguration;
+import fr.gouv.tac.analytics.server.config.mongodb.MongoIndexCreationListener;
+import fr.gouv.tac.analytics.server.config.validation.ValidationConfiguration;
+import fr.gouv.tac.analytics.server.model.mongo.TokenIdentifier;
+import org.assertj.core.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.data.mongo.DataMongoTest;
+import org.springframework.context.annotation.Import;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.test.context.ActiveProfiles;
+
+import javax.validation.ConstraintViolationException;
+import java.time.ZonedDateTime;
+import java.util.Optional;
+
+@ActiveProfiles(value = "test")
+@DataMongoTest
+@Import(value = {MongoConfiguration.class, MongoIndexCreationListener.class, ValidationConfiguration.class})
+public class TokenIdentifierRepositoryTest {
+
+    @Autowired
+    private TokenIdentifierRepository tokenIdentifierRepository;
+
+    @BeforeEach
+    public void setUp() {
+        tokenIdentifierRepository.deleteAll();
+    }
+
+    @Test
+    public void shouldSaveTokenIdentifierThenFindItById() {
+
+        final TokenIdentifier tokenIdentifier = TokenIdentifier.builder().identifier("someIdentfier").expirationDate(ZonedDateTime.now()).build();
+
+        final TokenIdentifier savedTokenIdentifier = tokenIdentifierRepository.save(tokenIdentifier);
+        Assertions.assertThat(savedTokenIdentifier.getId()).isNotBlank();
+        Assertions.assertThat(savedTokenIdentifier.getIdentifier()).isEqualTo(tokenIdentifier.getIdentifier());
+        Assertions.assertThat(savedTokenIdentifier.getExpirationDate()).isEqualToIgnoringSeconds(tokenIdentifier.getExpirationDate());
+
+
+        final Optional<TokenIdentifier> result = tokenIdentifierRepository.findById(savedTokenIdentifier.getId());
+        Assertions.assertThat(result).isPresent();
+        Assertions.assertThat(result.get().getId()).isEqualTo(savedTokenIdentifier.getId());
+        Assertions.assertThat(result.get().getIdentifier()).isEqualTo(savedTokenIdentifier.getIdentifier());
+        Assertions.assertThat(savedTokenIdentifier.getExpirationDate()).isEqualToIgnoringSeconds(tokenIdentifier.getExpirationDate());
+    }
+
+    @Test
+    public void shouldSaveTokenIdentifierThenFindItByIdentifier() {
+
+        final TokenIdentifier tokenIdentifier = TokenIdentifier.builder().identifier("someIdentfier").expirationDate(ZonedDateTime.now()).build();
+
+        final TokenIdentifier savedTokenIdentifier = tokenIdentifierRepository.save(tokenIdentifier);
+        Assertions.assertThat(savedTokenIdentifier.getId()).isNotBlank();
+        Assertions.assertThat(savedTokenIdentifier.getIdentifier()).isEqualTo(tokenIdentifier.getIdentifier());
+        Assertions.assertThat(savedTokenIdentifier.getExpirationDate()).isEqualToIgnoringSeconds(tokenIdentifier.getExpirationDate());
+
+
+        final Optional<TokenIdentifier> result = tokenIdentifierRepository.findByIdentifier(tokenIdentifier.getIdentifier());
+        Assertions.assertThat(result).isPresent();
+        Assertions.assertThat(result.get().getId()).isEqualTo(savedTokenIdentifier.getId());
+        Assertions.assertThat(result.get().getIdentifier()).isEqualTo(savedTokenIdentifier.getIdentifier());
+        Assertions.assertThat(savedTokenIdentifier.getExpirationDate()).isEqualToIgnoringSeconds(tokenIdentifier.getExpirationDate());
+    }
+
+    @Test()
+    public void shouldNotAllowToSaveTheSameIdentifierTwice() {
+
+        final TokenIdentifier tokenIdentifier1 = TokenIdentifier.builder().identifier("someIdentfier").expirationDate(ZonedDateTime.now()).build();
+        final TokenIdentifier tokenIdentifier2 = TokenIdentifier.builder().identifier("someIdentfier").expirationDate(ZonedDateTime.now()).build();
+
+        tokenIdentifierRepository.save(tokenIdentifier1);
+        Assertions.assertThatExceptionOfType(DuplicateKeyException.class).isThrownBy(() -> tokenIdentifierRepository.save(tokenIdentifier2));
+    }
+
+    @Test
+    public void shouldNotAllowToSaveWithoutIdentifier() {
+        final TokenIdentifier tokenIdentifier = TokenIdentifier.builder().expirationDate(ZonedDateTime.now()).build();
+        Assertions.assertThatThrownBy(() -> tokenIdentifierRepository.save(tokenIdentifier)).isInstanceOf(ConstraintViolationException.class);
+    }
+
+    @Test
+    public void shouldNotAllowToSaveWithoutExpiration() {
+        final TokenIdentifier tokenIdentifier = TokenIdentifier.builder().identifier("someIdentifier").build();
+        Assertions.assertThatThrownBy(() -> tokenIdentifierRepository.save(tokenIdentifier)).isInstanceOf(ConstraintViolationException.class);
+    }
+}
\ No newline at end of file
diff --git a/analytics-server/src/test/java/fr/gouv/tac/analytics/server/service/TokenIdentifierServiceTest.java b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/service/TokenIdentifierServiceTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..ab81e710cb9bd86e68b6270fa6294bc3509daafe
--- /dev/null
+++ b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/service/TokenIdentifierServiceTest.java
@@ -0,0 +1,75 @@
+package fr.gouv.tac.analytics.server.service;
+
+
+import java.time.ZonedDateTime;
+import java.util.Optional;
+
+import org.springframework.test.context.junit.jupiter.SpringExtension;
+
+import fr.gouv.tac.analytics.server.model.mongo.TokenIdentifier;
+import fr.gouv.tac.analytics.server.repository.mongo.TokenIdentifierRepository;
+import org.assertj.core.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+
+@ExtendWith(SpringExtension.class)
+public class TokenIdentifierServiceTest {
+
+    @Captor
+    private ArgumentCaptor<TokenIdentifier> tokenIdentifierArgumentCaptor;
+
+    @Mock
+    private TokenIdentifierRepository tokenIdentifierRepository;
+
+    @InjectMocks
+    private TokenIdentifierService tokenIdentifierService;
+
+
+    @Test
+    public void shouldSayIdentifierExistWhenItIsInDb() {
+
+        final String tokenIdentifier = "someId";
+
+        Mockito.when(tokenIdentifierRepository.findByIdentifier(tokenIdentifier)).thenReturn(Optional.of(new TokenIdentifier()));
+
+        final boolean result = tokenIdentifierService.tokenIdentifierExist(tokenIdentifier);
+        Assertions.assertThat(result).isTrue();
+    }
+
+    @Test
+    public void shouldSayIdentifierDoesNotExistWhenItIsNotInDb() {
+
+        final String tokenIdentifier = "someId";
+
+        Mockito.when(tokenIdentifierRepository.findByIdentifier(tokenIdentifier)).thenReturn(Optional.empty());
+
+        final boolean result = tokenIdentifierService.tokenIdentifierExist(tokenIdentifier);
+        Assertions.assertThat(result).isFalse();
+    }
+
+    @Test
+    public void shouldSaveTokenIdentifierInDb() {
+
+        final String tokenIdentifier = "someId";
+        final ZonedDateTime expirationDate = ZonedDateTime.now();
+        final TokenIdentifier ti = new TokenIdentifier();
+
+        Mockito.when(tokenIdentifierRepository.save(tokenIdentifierArgumentCaptor.capture())).thenReturn(ti);
+
+        final TokenIdentifier result = tokenIdentifierService.save(tokenIdentifier, expirationDate);
+
+        Assertions.assertThat(result).isEqualTo(ti);
+
+        final TokenIdentifier capturedTokenIdenfier = tokenIdentifierArgumentCaptor.getValue();
+        Assertions.assertThat(capturedTokenIdenfier.getId()).isNull();
+        Assertions.assertThat(capturedTokenIdenfier.getIdentifier()).isEqualTo(tokenIdentifier);
+        Assertions.assertThat(capturedTokenIdenfier.getExpirationDate()).isEqualTo(expirationDate);
+
+
+    }
+}
\ No newline at end of file
diff --git a/analytics-server/src/test/java/fr/gouv/tac/analytics/server/utils/TestUtils.java b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/utils/TestUtils.java
new file mode 100644
index 0000000000000000000000000000000000000000..a86647f8c6010d3b1e33764fe35fccbf659cf2d5
--- /dev/null
+++ b/analytics-server/src/test/java/fr/gouv/tac/analytics/server/utils/TestUtils.java
@@ -0,0 +1,16 @@
+package fr.gouv.tac.analytics.server.utils;
+
+import fr.gouv.tac.analytics.server.controller.vo.TimestampedEventVo;
+import fr.gouv.tac.analytics.server.model.kafka.TimestampedEvent;
+
+public class TestUtils {
+
+    public static TimestampedEvent convertTimestampedEvent(final TimestampedEventVo timestampedEventVo) {
+        return TimestampedEvent.builder()
+                .name(timestampedEventVo.getName())
+                .timestamp(timestampedEventVo.getTimestamp())
+                .description(timestampedEventVo.getDescription())
+                .build();
+    }
+
+}
diff --git a/analytics-server/src/test/resources/application-test.properties b/analytics-server/src/test/resources/application-test.properties
new file mode 100644
index 0000000000000000000000000000000000000000..d1a35d42d748d86b155b57d123979bb1ba2b70fc
--- /dev/null
+++ b/analytics-server/src/test/resources/application-test.properties
@@ -0,0 +1,18 @@
+spring.main.banner-mode=off
+spring.data.mongodb.uri=mongodb://localhost:27018/protectedAppAnalyticsDBTest
+analyticsserver.robert_jwt_analyticspublickey=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0OP2qCk0PHb+i8tZ/yz8A4iFbw2YV9jGuTkxwoquC4bahR6+STEBQdJ9gvshSCQDWFneXL9Takn2Tse/5LwXQf4lpsqo0f4He/uXQqkpX+4RqOT+WMl9Br6xOIWd3Z6EX2rHbDVYBStbltMbuTgbiaq9EkPab9BdeYrwR8uG6lXMB2ftC8JmeFIt9TMU2CAjwFwkLPfnW2e0R3SMshz8hUVeZu8YJCmMmQxzerbSTmywB6x6gr443tVNm2yOsFhJN4YWYIVAgYRkwQctnRYbaD97awLOCmGLwYEChUDcgm/VkMfmVyuVU7+5Cln3By6GRZze9JLc8Gxvxr4J7Gg3dwIDAQAB
+# related private key
+analyticsserver.robert_jwt_analyticsprivatekey=MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDQ4/aoKTQ8dv6Ly1n/LPwDiIVvDZhX2Ma5OTHCiq4LhtqFHr5JMQFB0n2C+yFIJANYWd5cv1NqSfZOx7/kvBdB/iWmyqjR/gd7+5dCqSlf7hGo5P5YyX0GvrE4hZ3dnoRfasdsNVgFK1uW0xu5OBuJqr0SQ9pv0F15ivBHy4bqVcwHZ+0LwmZ4Ui31MxTYICPAXCQs9+dbZ7RHdIyyHPyFRV5m7xgkKYyZDHN6ttJObLAHrHqCvjje1U2bbI6wWEk3hhZghUCBhGTBBy2dFhtoP3trAs4KYYvBgQKFQNyCb9WQx+ZXK5VTv7kKWfcHLoZFnN70ktzwbG/GvgnsaDd3AgMBAAECggEAN/eMA5wekcC0DJJsR3EvCGdQkOOMmKTNAZ1wVpY/cXktHROSmhuWIaOa2zgbv69echKKAEGGwOiWJJ9iK4+1j4nfXqPXvYOZT7+l1EdsfXZUpvLLrtA1PlRjOSiblmA9SS9bxQM51RC71loziFmfDzB+veEOKn0iPklafXHrcOcVx3ukus9PZP6gWdC3TwMFsoq9Mrfmvd7BlbC7Hlwerb7YBeoA5wNudvv64fKsOAieP39AytRJappq4w62plGARKqV3BP9BYc0wkEa7euSE3sF7iVjDXw3ugrPi8dTlZAdiRZUQguSc1uuLNUeIbLAbCBfvjIyLC+nfogxQdui0QKBgQD049VOEsyCpkwQb3slGBYaOTxOUMx6zGJwTxVsH3gfEnjdOyrvAjZyJDb6huOsn5QrCJIUOqByAIqp+Nx3P4eXYDnL4G4GmdSJLlm8tKIaV8EYCxsRRhYqTbgjzHnhWL8MkcgltwQkAFBwEnra43iCj1Doz39+hmNK313e4RomHwKBgQDaXgewy+jWnyB4SZAW07O3ezEo1uPy9IAp0qKtLOfE+Le/MgF/u+68tGTGh9Oy45E8lC9Yj69+mYmWAsr3r63xgcNAW6T3QUYukR6W1+I1xK3lgg9nsp7JU6xFbNl6IvlNPjelGB2yRzrVyIre3WkmabMN8dgvgbNdD56SNRdTqQKBgEdAMsOwfIhW0jLF/NJiG6wtkvpGT/g6lzmOCPGYhl6kBT55BJjdz/GNz+E8dem31Ghg6f+wvxXsSmwB2ENp3I2Slb5X4itRfqEbN0jMVY3MkoXoVUvFVJWiXz0rNRr3sz54+/7dLPs8jCrPdadSH7H8+NGD7dhmSWVzb+B1JiKJAoGAeWCCzdbJ2WIh3jqliMqrvnUPYi/wDH+zLYwTOEcZnPbSy8ez5ZhEn0d2LJQbK/gqJo4HsyZK2gfl3ig3QW25NeB28zL9gyIZLJEle5sL9e8Y6dVdUrqKYEXOH9jdGXKPOEw5Cd9ZwlqtbV9HMZHIfL2L9VhUXjOtyzB/Z61zBeECgYEAu4Q89O4ZBNwo0CdK1xMyqs67piY4DEpHx5PBnBqobFGHCOxhgTCCeJvK07KjzfR0OM6P7GpwdPoH49WMECblCn8xfu90Q3OLw7XyUgBQngQ1a7TP1fgX+SxEIpfuKHzXSvuzgWrFSij31x4iVlwqItSGgS+7AZwiwKDk+1L22Zk
+analyticsserver.validation.analytics.information.maxInfoAllowed=2
+analyticsserver.validation.analytics.information.maxInfoKeyLength=10
+analyticsserver.validation.analytics.information.maxInfoValueLength=12
+analyticsserver.validation.analytics.event.maxElementAllowed=2
+analyticsserver.validation.analytics.event.maxNameLength=10
+analyticsserver.validation.analytics.event.maxDescriptionLength=20
+analyticsserver.validation.analytics.error.maxElementAllowed=2
+analyticsserver.validation.analytics.error.maxNameLength=10
+analyticsserver.validation.analytics.error.maxDescriptionLength=20
+spring.kafka.bootstrap-servers=localhost:9094
+spring.kafka.consumer.group-id=identifierGroupTest
+spring.kafka.consumer.auto-offset-reset=earliest
+spring.kafka.template.default-topic=topicNameForTest
diff --git a/analytics-server/src/test/resources/bootstrap.yml b/analytics-server/src/test/resources/bootstrap.yml
new file mode 100644
index 0000000000000000000000000000000000000000..bbf174bb2753135352f4f2a0fe97d68660b30c57
--- /dev/null
+++ b/analytics-server/src/test/resources/bootstrap.yml
@@ -0,0 +1,10 @@
+spring:
+  application:
+    name: analytics-server
+  cloud:
+    consul:
+      enabled: false
+
+    vault:
+      enabled: false
+
diff --git a/analytics-server/src/test/resources/logback-test.xml b/analytics-server/src/test/resources/logback-test.xml
new file mode 100644
index 0000000000000000000000000000000000000000..381c9aa65088e83b2f713fc5e7eae60e2f1bc26e
--- /dev/null
+++ b/analytics-server/src/test/resources/logback-test.xml
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<configuration scan="true" scanPeriod="30 seconds">
+
+    <property name="PATTERN"
+              value="%d{yyyy-MM-dd'T'HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"/>
+
+    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
+        <encoder>
+            <pattern>${PATTERN}</pattern>
+            <charset>utf8</charset>
+        </encoder>
+    </appender>
+
+
+    <!-- Default logger -->
+    <root level="WARN">
+        <appender-ref ref="CONSOLE"/>
+    </root>
+
+</configuration>
\ No newline at end of file
diff --git a/clea/.gitignore b/clea/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..549e00a2a96fa9d7c5dbc9859664a78d980158c2
--- /dev/null
+++ b/clea/.gitignore
@@ -0,0 +1,33 @@
+HELP.md
+target/
+!.mvn/wrapper/maven-wrapper.jar
+!**/src/main/**/target/
+!**/src/test/**/target/
+
+### STS ###
+.apt_generated
+.classpath
+.factorypath
+.project
+.settings
+.springBeans
+.sts4-cache
+
+### IntelliJ IDEA ###
+.idea
+*.iws
+*.iml
+*.ipr
+
+### NetBeans ###
+/nbproject/private/
+/nbbuild/
+/dist/
+/nbdist/
+/.nb-gradle/
+build/
+!**/src/main/**/build/
+!**/src/test/**/build/
+
+### VS Code ###
+.vscode/
diff --git a/clea/.mvn/wrapper/MavenWrapperDownloader.java b/clea/.mvn/wrapper/MavenWrapperDownloader.java
new file mode 100644
index 0000000000000000000000000000000000000000..a45eb6ba269cd38f8965cef786729790945d9537
--- /dev/null
+++ b/clea/.mvn/wrapper/MavenWrapperDownloader.java
@@ -0,0 +1,118 @@
+/*
+ * Copyright 2007-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import java.net.*;
+import java.io.*;
+import java.nio.channels.*;
+import java.util.Properties;
+
+public class MavenWrapperDownloader {
+
+    private static final String WRAPPER_VERSION = "0.5.6";
+    /**
+     * Default URL to download the maven-wrapper.jar from, if no 'downloadUrl' is provided.
+     */
+    private static final String DEFAULT_DOWNLOAD_URL = "https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/"
+            + WRAPPER_VERSION + "/maven-wrapper-" + WRAPPER_VERSION + ".jar";
+
+    /**
+     * Path to the maven-wrapper.properties file, which might contain a downloadUrl property to
+     * use instead of the default one.
+     */
+    private static final String MAVEN_WRAPPER_PROPERTIES_PATH =
+            ".mvn/wrapper/maven-wrapper.properties";
+
+    /**
+     * Path where the maven-wrapper.jar will be saved to.
+     */
+    private static final String MAVEN_WRAPPER_JAR_PATH =
+            ".mvn/wrapper/maven-wrapper.jar";
+
+    /**
+     * Name of the property which should be used to override the default download url for the wrapper.
+     */
+    private static final String PROPERTY_NAME_WRAPPER_URL = "wrapperUrl";
+
+    public static void main(String args[]) {
+        System.out.println("- Downloader started");
+        File baseDirectory = new File(args[0]);
+        System.out.println("- Using base directory: " + baseDirectory.getAbsolutePath());
+
+        // If the maven-wrapper.properties exists, read it and check if it contains a custom
+        // wrapperUrl parameter.
+        File mavenWrapperPropertyFile = new File(baseDirectory, MAVEN_WRAPPER_PROPERTIES_PATH);
+        String url = DEFAULT_DOWNLOAD_URL;
+        if (mavenWrapperPropertyFile.exists()) {
+            FileInputStream mavenWrapperPropertyFileInputStream = null;
+            try {
+                mavenWrapperPropertyFileInputStream = new FileInputStream(mavenWrapperPropertyFile);
+                Properties mavenWrapperProperties = new Properties();
+                mavenWrapperProperties.load(mavenWrapperPropertyFileInputStream);
+                url = mavenWrapperProperties.getProperty(PROPERTY_NAME_WRAPPER_URL, url);
+            } catch (IOException e) {
+                System.out.println("- ERROR loading '" + MAVEN_WRAPPER_PROPERTIES_PATH + "'");
+            } finally {
+                try {
+                    if (mavenWrapperPropertyFileInputStream != null) {
+                        mavenWrapperPropertyFileInputStream.close();
+                    }
+                } catch (IOException e) {
+                    // Ignore ...
+                }
+            }
+        }
+        System.out.println("- Downloading from: " + url);
+
+        File outputFile = new File(baseDirectory.getAbsolutePath(), MAVEN_WRAPPER_JAR_PATH);
+        if (!outputFile.getParentFile().exists()) {
+            if (!outputFile.getParentFile().mkdirs()) {
+                System.out.println(
+                        "- ERROR creating output directory '" + outputFile.getParentFile().getAbsolutePath() + "'");
+            }
+        }
+        System.out.println("- Downloading to: " + outputFile.getAbsolutePath());
+        try {
+            downloadFileFromURL(url, outputFile);
+            System.out.println("Done");
+            System.exit(0);
+        } catch (Throwable e) {
+            System.out.println("- Error downloading");
+            e.printStackTrace();
+            System.exit(1);
+        }
+    }
+
+    private static void downloadFileFromURL(String urlString, File destination) throws Exception {
+        if (System.getenv("MVNW_USERNAME") != null && System.getenv("MVNW_PASSWORD") != null) {
+            String username = System.getenv("MVNW_USERNAME");
+            char[] password = System.getenv("MVNW_PASSWORD").toCharArray();
+            Authenticator.setDefault(new Authenticator() {
+                @Override
+                protected PasswordAuthentication getPasswordAuthentication() {
+                    return new PasswordAuthentication(username, password);
+                }
+            });
+        }
+        URL website = new URL(urlString);
+        ReadableByteChannel rbc;
+        rbc = Channels.newChannel(website.openStream());
+        FileOutputStream fos = new FileOutputStream(destination);
+        fos.getChannel().transferFrom(rbc, 0, Long.MAX_VALUE);
+        fos.close();
+        rbc.close();
+    }
+
+}
diff --git a/clea/.mvn/wrapper/maven-wrapper.jar b/clea/.mvn/wrapper/maven-wrapper.jar
new file mode 100644
index 0000000000000000000000000000000000000000..2cc7d4a55c0cd0092912bf49ae38b3a9e3fd0054
Binary files /dev/null and b/clea/.mvn/wrapper/maven-wrapper.jar differ
diff --git a/clea/.mvn/wrapper/maven-wrapper.properties b/clea/.mvn/wrapper/maven-wrapper.properties
new file mode 100644
index 0000000000000000000000000000000000000000..642d572ce90e5085986bdd9c9204b9404f028084
--- /dev/null
+++ b/clea/.mvn/wrapper/maven-wrapper.properties
@@ -0,0 +1,2 @@
+distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.6.3/apache-maven-3.6.3-bin.zip
+wrapperUrl=https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar
diff --git a/clea/README.md b/clea/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..1b80b15ada7761c6b6ba7aef70e64a19f6c27e42
--- /dev/null
+++ b/clea/README.md
@@ -0,0 +1,31 @@
+## CLEA coding guidelines
+
+* Contributions are done through Merge Requests on the develop branch (gitflow). MR must be reviewed by another dev than
+  the code author. a MR can be merged when approved by at least one reviewer.
+* Development happens on feature branches.
+* Commit often, small commits, and publish them on the central repositories to share the lastest developments with the
+  dev team.
+* Tests are run as part of the CI pipeline and should stay "green"
+* Use Meaningful names (very important): classes, methods, variables
+* Unit test the code
+* Small methods
+* Methods do one thing, only one level of abstraction
+* DRY: Don't Repeat Yourself
+* SOLID
+    * S: Single-responsibility principle: a class should only have a single responsibility, that is, only changes to one
+      part of the software's specification should be able to affect the specification of the class.
+    * O: Open–closed principle: "software entities ... should be open for extension, but closed for modification."
+    * L: Liskov substitution principle: "objects in a program should be replaceable with instances of their subtypes
+      without altering the correctness of that program."
+    * I: Interface segregation principle: "many client-specific interfaces are better than one general-purpose
+      interface."
+    * D: Dependency inversion principle: "depend upon abstractions, [not] concretions."
+* Comments
+    * Do not paraphrase the code. Make the code self-documentating.
+    * Comment only what is not obvious by reading the code: informative comment, explanation of intent, warning. Use
+      javadoc for public APIs.
+* Formatting
+    * Naming conventions: Camel case.
+    * Unit of indentation is 4 spaces. No use of the tab character.
+    * Curly braces at the end of the line that starts the class, method, loop, etc., and the closing brace is on a line
+      by itself, lined up vertically with the start of the first line.
diff --git a/clea/clea-client/clusters/1/pJivA.json b/clea/clea-client/clusters/1/pJivA.json
new file mode 100644
index 0000000000000000000000000000000000000000..754c1accf23095934e1aac776ac66d8ea4d3c09e
--- /dev/null
+++ b/clea/clea-client/clusters/1/pJivA.json
@@ -0,0 +1,8 @@
+[
+	{
+		"ltid":"pJivAjyHMeSsm/SZ/1fcjQ==",
+		"exp" : [
+			{"s": "3824820000", "d" : 3, "r" : 2.0}
+		]	
+	}
+]
diff --git a/clea/clea-client/clusters/index.json b/clea/clea-client/clusters/index.json
new file mode 100644
index 0000000000000000000000000000000000000000..d88e13865a6ad7d92db3ff3cf3841825cde5c212
--- /dev/null
+++ b/clea/clea-client/clusters/index.json
@@ -0,0 +1,6 @@
+{
+	"i" : 1,
+	"c" : [
+		"pJivA"
+	]
+}
diff --git a/clea/clea-client/pom.xml b/clea/clea-client/pom.xml
new file mode 100644
index 0000000000000000000000000000000000000000..857391a51ec965a1e15829c50a844ca1b98a1f4c
--- /dev/null
+++ b/clea/clea-client/pom.xml
@@ -0,0 +1,54 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+    <parent>
+        <groupId>fr.gouv.clea</groupId>
+        <artifactId>clea-server</artifactId>
+        <version>0.1-SNAPSHOT</version>
+    </parent>
+
+    <artifactId>clea-client</artifactId>
+    <name>clea-client</name>
+    <description>Client simulating status request for Clea</description>
+
+    <properties>
+        <java.version>11</java.version>
+        <maven.compiler.source>11</maven.compiler.source>
+        <maven.compiler.target>11</maven.compiler.target>
+        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+    </properties>
+
+    <dependencies>
+        <dependency>
+            <groupId>org.slf4j</groupId>
+            <artifactId>slf4j-jdk14</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.junit.jupiter</groupId>
+            <artifactId>junit-jupiter</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>com.fasterxml.jackson.core</groupId>
+            <artifactId>jackson-databind</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.projectlombok</groupId>
+            <artifactId>lombok</artifactId>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.mockito</groupId>
+            <artifactId>mockito-core</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.assertj</groupId>
+            <artifactId>assertj-core</artifactId>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
+</project>
diff --git a/clea/clea-client/src/main/java/fr/gouv/clea/client/configuration/CleaClientConfiguration.java b/clea/clea-client/src/main/java/fr/gouv/clea/client/configuration/CleaClientConfiguration.java
new file mode 100644
index 0000000000000000000000000000000000000000..954494f2007347acd60486c70a4ab470a96cd752
--- /dev/null
+++ b/clea/clea-client/src/main/java/fr/gouv/clea/client/configuration/CleaClientConfiguration.java
@@ -0,0 +1,72 @@
+package fr.gouv.clea.client.configuration;
+
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Properties;
+
+public class CleaClientConfiguration {
+    private static CleaClientConfiguration instance;
+    private static String configFile = "application.properties";
+    private Properties config;
+
+    public CleaClientConfiguration() {
+        this.config = new Properties();
+    }
+
+    public static CleaClientConfiguration getInstance() throws IOException {
+        if (instance == null) {
+            instance = new CleaClientConfiguration();
+            instance.initialize();
+        }
+
+        return instance;
+    }
+
+    public void initialize() throws IOException {
+        InputStream inputStream = getClass().getClassLoader().getResourceAsStream(configFile);
+        if (inputStream != null) {
+            this.config.load(inputStream);
+        } else {
+            throw new FileNotFoundException("config file '" + configFile + "' doesn't exists.");
+        }
+        inputStream.close();
+    }
+
+    public String getBackendUrl() {
+        return this.config.getProperty("backend_url", "");
+    }
+    
+    public String getReportPath() {
+        return this.config.getProperty("report_path", "");
+    }
+    
+    public String getStatusPath() {
+        return this.config.getProperty("status_path", "");
+    }
+
+    public String getIndexFilename(){
+        return this.config.getProperty("index_filename","");
+    }
+    
+    public String getQrPrefix() {
+        return this.config.getProperty("qrprefix", "");
+    }
+
+
+    public int getDurationUnitInSeconds(){
+        try{
+            return Integer.parseInt(this.config.getProperty("duration_unit", ""));
+        }catch(NumberFormatException e){
+            return 0;
+        }
+    }
+    
+    public int getDupScanThreshold() {
+        try {
+            return Integer.parseInt(this.config.getProperty("dup_scan_threshold", ""));
+        } catch (NumberFormatException e) {
+            return 0;
+        }
+    }
+}
diff --git a/clea/clea-client/src/main/java/fr/gouv/clea/client/model/Cluster.java b/clea/clea-client/src/main/java/fr/gouv/clea/client/model/Cluster.java
new file mode 100644
index 0000000000000000000000000000000000000000..7b7fa1dfc0e27e2c215f7b2fce5e7a00d469737a
--- /dev/null
+++ b/clea/clea-client/src/main/java/fr/gouv/clea/client/model/Cluster.java
@@ -0,0 +1,20 @@
+package fr.gouv.clea.client.model;
+
+import java.util.List;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class Cluster {
+    @JsonProperty("ltid")
+    private String locationTemporaryPublicID; //LTid
+
+    @JsonProperty("exp")
+    private List<ClusterExposition> expositions;
+}
diff --git a/clea/clea-client/src/main/java/fr/gouv/clea/client/model/ClusterExposition.java b/clea/clea-client/src/main/java/fr/gouv/clea/client/model/ClusterExposition.java
new file mode 100644
index 0000000000000000000000000000000000000000..5482e9d785be35dd487417ace3a79bb0b6876261
--- /dev/null
+++ b/clea/clea-client/src/main/java/fr/gouv/clea/client/model/ClusterExposition.java
@@ -0,0 +1,29 @@
+package fr.gouv.clea.client.model;
+
+import java.io.IOException;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import fr.gouv.clea.client.configuration.CleaClientConfiguration;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@AllArgsConstructor
+@NoArgsConstructor
+public class ClusterExposition {
+    @JsonProperty("s")
+    private long startTimeAsNtpTimestamp;
+    
+    @JsonProperty("d")
+    private int nbDurationUnit;
+
+    @JsonProperty("r")
+    private float risk;
+
+    public boolean isInExposition(long ntpTimestamp) throws IOException {
+        return ((ntpTimestamp >= startTimeAsNtpTimestamp) 
+                && (ntpTimestamp <= startTimeAsNtpTimestamp + nbDurationUnit * CleaClientConfiguration.getInstance().getDurationUnitInSeconds())); 
+    }
+}
diff --git a/clea/clea-client/src/main/java/fr/gouv/clea/client/model/ClusterIndex.java b/clea/clea-client/src/main/java/fr/gouv/clea/client/model/ClusterIndex.java
new file mode 100644
index 0000000000000000000000000000000000000000..e47fe9fc09868380e8bd0b0bed80536a680744f4
--- /dev/null
+++ b/clea/clea-client/src/main/java/fr/gouv/clea/client/model/ClusterIndex.java
@@ -0,0 +1,20 @@
+package fr.gouv.clea.client.model;
+
+import java.util.List;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class ClusterIndex {
+    @JsonProperty("i")
+    private int iteration;
+    
+    @JsonProperty("c")
+    private List<String> prefixes;
+}
diff --git a/clea/clea-client/src/main/java/fr/gouv/clea/client/model/Report.java b/clea/clea-client/src/main/java/fr/gouv/clea/client/model/Report.java
new file mode 100644
index 0000000000000000000000000000000000000000..a91505392a3b9bb551d9a63d59fa0e988dc9d2a4
--- /dev/null
+++ b/clea/clea-client/src/main/java/fr/gouv/clea/client/model/Report.java
@@ -0,0 +1,24 @@
+package fr.gouv.clea.client.model;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import lombok.Data;
+
+@Data
+public class Report {
+
+    List<Visit> visits;
+
+    public Report(){
+        this.visits = new ArrayList<>();
+    }
+
+    public void addVisit(ScannedQrCode scannedQr){
+        this.visits.add(new Visit(scannedQr.getQrCode(), scannedQr.getScanTime()));
+    }
+
+    public void addAllVisits(List<ScannedQrCode> localList) {
+        localList.forEach(this::addVisit);
+    }
+}
diff --git a/clea/clea-client/src/main/java/fr/gouv/clea/client/model/ReportResponse.java b/clea/clea-client/src/main/java/fr/gouv/clea/client/model/ReportResponse.java
new file mode 100644
index 0000000000000000000000000000000000000000..971dafa157775b76052f742fcc22a6d1b3286af0
--- /dev/null
+++ b/clea/clea-client/src/main/java/fr/gouv/clea/client/model/ReportResponse.java
@@ -0,0 +1,13 @@
+package fr.gouv.clea.client.model;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@AllArgsConstructor
+@NoArgsConstructor
+public class ReportResponse {
+    private boolean success;
+    private String message;
+}
diff --git a/clea/clea-client/src/main/java/fr/gouv/clea/client/model/ScannedQrCode.java b/clea/clea-client/src/main/java/fr/gouv/clea/client/model/ScannedQrCode.java
new file mode 100644
index 0000000000000000000000000000000000000000..3b6d8f6968801bf9060de4b05a77a65cdd302dd0
--- /dev/null
+++ b/clea/clea-client/src/main/java/fr/gouv/clea/client/model/ScannedQrCode.java
@@ -0,0 +1,45 @@
+package fr.gouv.clea.client.model;
+
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Optional;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@NoArgsConstructor
+public class ScannedQrCode {
+
+    private String qrCode;
+
+    @JsonProperty("qrCodeScanTime")
+    private long scanTime;
+
+    @JsonIgnore
+    private Optional<String> locationTemporaryId; //LTId
+
+    public ScannedQrCode(String qrCode, long scanTime){
+        this.qrCode = qrCode;
+        this.scanTime = scanTime;
+        this.locationTemporaryId = Optional.empty();
+    }
+
+    public String getLocationTemporaryId() {
+        return locationTemporaryId.orElse(this.decodeLocationTemporaryId());
+    }
+    
+    private String decodeLocationTemporaryId() {
+        byte[] tlIdByte = Arrays.copyOfRange(Base64.getDecoder().decode(qrCode), 1, 17) ;
+        locationTemporaryId = Optional.of(Base64.getEncoder().encodeToString(tlIdByte));
+        return locationTemporaryId.get();
+    }
+
+    public boolean startWithPrefix(String prefix){
+        return this.getLocationTemporaryId().startsWith(prefix);
+    }
+    
+}
\ No newline at end of file
diff --git a/clea/clea-client/src/main/java/fr/gouv/clea/client/model/Visit.java b/clea/clea-client/src/main/java/fr/gouv/clea/client/model/Visit.java
new file mode 100644
index 0000000000000000000000000000000000000000..587eb35674af89bf4cab7e82e0c6f2b4756b1ead
--- /dev/null
+++ b/clea/clea-client/src/main/java/fr/gouv/clea/client/model/Visit.java
@@ -0,0 +1,18 @@
+package fr.gouv.clea.client.model;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class Visit {
+
+    private String qrCode;
+    
+    @JsonProperty("qrCodeScanTime")
+    private long scanTime;
+}
diff --git a/clea/clea-client/src/main/java/fr/gouv/clea/client/service/CleaClient.java b/clea/clea-client/src/main/java/fr/gouv/clea/client/service/CleaClient.java
new file mode 100644
index 0000000000000000000000000000000000000000..90bf9ed9a82b532bf7c3bccdc5226298e4024b1b
--- /dev/null
+++ b/clea/clea-client/src/main/java/fr/gouv/clea/client/service/CleaClient.java
@@ -0,0 +1,66 @@
+package fr.gouv.clea.client.service;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+
+import fr.gouv.clea.client.configuration.CleaClientConfiguration;
+import fr.gouv.clea.client.model.ScannedQrCode;
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+public class CleaClient {
+    private List<ScannedQrCode> localList;
+
+    public CleaClient() {
+        this.localList = new ArrayList<>();
+    }
+
+    public List<ScannedQrCode> getLocalList() {
+        return this.localList;
+    }
+
+    /**
+     * verify the validity of the qr code and add it to the local list
+     * @param qrCode : the plain-text  representing the scanned qr code. format : PREFIX + BASE64(LSP)
+     * @param timestamp : timestamp (in seconds) of the scan
+     * @return true if qr code has been added to the local list, false otherwise
+     */
+    public boolean scanQrCode(String qrCode, long timestamp) {
+        CleaClientConfiguration configuration;
+        try {
+             configuration = CleaClientConfiguration.getInstance();
+        } catch (IOException e) {
+            log.error("Can't access config file, scanning can't proceed.");
+            return false;
+        }
+
+        //check if prefix is present then removes it
+        if (!qrCode.startsWith(configuration.getQrPrefix())) {
+            return false;
+        }
+        qrCode = qrCode.substring(configuration.getQrPrefix().length());
+        ScannedQrCode scannedQr  = new ScannedQrCode(qrCode, timestamp);
+
+        //Check for duplicate in local list
+        for (ScannedQrCode prevQR : this.localList) {
+            if(scannedQr.getLocationTemporaryId().equals(prevQR.getLocationTemporaryId()) && Math.abs(prevQR.getScanTime() - timestamp) <= configuration.getDupScanThreshold()){
+                return false;
+            }
+        }
+        localList.add(scannedQr);
+        return true;
+    }
+
+    /**
+     * Add a batch of Qr code to the local list, verifying their validity beforehand 
+     * @param qrcodes : list of qr code and their timestamp to add to the local list
+     */
+    public void batchScanQrCode(Map<Long, String> qrcodes) {
+        for (Entry<Long, String> qrcode : qrcodes.entrySet()) {
+            scanQrCode(qrcode.getValue(),qrcode.getKey());
+        }
+    }
+}
diff --git a/clea/clea-client/src/main/java/fr/gouv/clea/client/service/ReportService.java b/clea/clea-client/src/main/java/fr/gouv/clea/client/service/ReportService.java
new file mode 100644
index 0000000000000000000000000000000000000000..f47e3da89f5d001f1116afecc91f05effd169380
--- /dev/null
+++ b/clea/clea-client/src/main/java/fr/gouv/clea/client/service/ReportService.java
@@ -0,0 +1,46 @@
+package fr.gouv.clea.client.service;
+
+import java.io.IOException;
+import java.util.List;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import fr.gouv.clea.client.model.Report;
+import fr.gouv.clea.client.model.ReportResponse;
+import fr.gouv.clea.client.model.ScannedQrCode;
+import fr.gouv.clea.client.utils.HttpClientWrapper;
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+public class ReportService {
+    private HttpClientWrapper httpClient;
+    private String reportEndPoint;
+
+    public ReportService(String reportEndPoint) throws IOException {
+        this(reportEndPoint, new HttpClientWrapper());
+    }
+
+    public ReportService(String reportEndPoint, HttpClientWrapper httpClient) throws IOException {
+        this.httpClient = httpClient;
+        this.reportEndPoint = reportEndPoint;
+    }
+
+    /**
+     * report a list of qr code to the backend server
+     */
+    public ReportResponse report(List<ScannedQrCode> localList) throws IOException, InterruptedException {
+        String jsonRequest;
+        Report reportRequest = new Report();
+        reportRequest.addAllVisits(localList);
+        
+        log.info("Reporting {} visits to {}", localList.size(), this.reportEndPoint);
+        jsonRequest = new ObjectMapper().writeValueAsString(reportRequest);
+        log.info(jsonRequest);
+        return this.post(jsonRequest);
+    }
+
+    protected ReportResponse post(String jsonRequest) throws IOException, InterruptedException {
+        return this.httpClient.post(this.reportEndPoint, jsonRequest, ReportResponse.class);
+    }
+
+}
diff --git a/clea/clea-client/src/main/java/fr/gouv/clea/client/service/StatusService.java b/clea/clea-client/src/main/java/fr/gouv/clea/client/service/StatusService.java
new file mode 100644
index 0000000000000000000000000000000000000000..a7e886a3cfeee8c666d582c2cb5216bcb704015a
--- /dev/null
+++ b/clea/clea-client/src/main/java/fr/gouv/clea/client/service/StatusService.java
@@ -0,0 +1,123 @@
+package fr.gouv.clea.client.service;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import fr.gouv.clea.client.configuration.CleaClientConfiguration;
+import fr.gouv.clea.client.model.Cluster;
+import fr.gouv.clea.client.model.ClusterExposition;
+import fr.gouv.clea.client.model.ClusterIndex;
+import fr.gouv.clea.client.model.ScannedQrCode;
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+public class StatusService {
+
+    private String indexPath;
+    private String indexFilename;
+    private ObjectMapper objectMapper;
+
+    public StatusService() throws IOException {
+        CleaClientConfiguration config = CleaClientConfiguration.getInstance();
+        this.indexPath = config.getStatusPath();
+        this.indexFilename = config.getIndexFilename();
+        objectMapper = new ObjectMapper();
+    }
+
+    /**
+     * Retrieve the cluster list and compare it to the local list to compute the
+     * risk score, returning the risk Level.
+     * 
+     * @param localList : LocalList of the simulated device, containing all the
+     *                  scanned QR code
+     * @return true if this localList is at risk, false otherwise
+     * @throws IOException 
+     */
+    public float status(List<ScannedQrCode> localList) throws IOException {
+        ClusterIndex clusterIndex = this.getClusterIndex().orElseThrow();
+        Set<String> matchingPrefixes = this.getClusterFilesMatchingPrefix(localList, clusterIndex);
+        int iteration = clusterIndex.getIteration();
+        List<Float> scores = new ArrayList<>();
+        // gather all potential clusters
+        for (String prefix : matchingPrefixes) {
+            Path currentPath = Path.of(this.indexPath, Integer.toString(iteration), prefix + ".json");
+            List<Cluster> clusters = this.readClusterFile(currentPath);
+                for (Cluster cluster : clusters) {
+                    for (ScannedQrCode qr : localList) {
+                        this.getQrRiskLevel(qr, cluster).ifPresent(risk -> scores.add(risk));
+                    }
+                }
+        }
+
+        return scores.stream().max(Comparator.naturalOrder()).orElse(0f);
+    }
+
+    protected Optional<Float> getQrRiskLevel(ScannedQrCode qr, Cluster cluster) throws IOException {
+        Optional<Float> result = Optional.empty();
+        if (qr.getLocationTemporaryId().equals(cluster.getLocationTemporaryPublicID())) {
+            for (ClusterExposition exposition : cluster.getExpositions()) {
+                if (exposition.isInExposition(qr.getScanTime())) {
+                    float newRisk = Math.max(result.orElse(0f), exposition.getRisk());
+                    result = Optional.of(newRisk);
+                }
+            }
+        }
+        return result;
+    }
+
+    protected Set<String> getClusterFilesMatchingPrefix(List<ScannedQrCode> localList, ClusterIndex clusterIndex) {
+        Set<String> matchingPrefixes = new HashSet<>();
+        for (String prefix : clusterIndex.getPrefixes()) {
+            for (ScannedQrCode qr : localList) {
+                if (qr.startWithPrefix(prefix)) {
+                    matchingPrefixes.add(prefix);
+                    break;
+                }
+            }
+        }
+        return matchingPrefixes;
+    }
+
+    protected Optional<ClusterIndex> getClusterIndex() {
+        String indexString;
+        try {
+            indexString = Files.readString(Path.of(this.indexPath, this.indexFilename), StandardCharsets.UTF_8);
+        } catch (IOException e) {
+            log.error("Error retrieving index file.", e);
+            return Optional.empty();
+        }
+        ClusterIndex clusterIndex;
+        try {
+            clusterIndex = objectMapper.readValue(indexString, ClusterIndex.class);
+        } catch (JsonProcessingException e) {
+            log.error("Error parsing JSON to create ClusterIndex Object.", e);
+            return Optional.empty();
+        }
+        return Optional.of(clusterIndex);
+    }
+
+    protected List<Cluster> readClusterFile(Path path) {
+        try {
+            String clustersString = Files.readString(path, StandardCharsets.UTF_8);
+            return objectMapper.readValue(clustersString, new TypeReference<List<Cluster>>() {});
+        } catch (IOException e) {
+            log.error("Could not open file :" + path.toString() + " computed score might be false.", e);
+            return Collections.emptyList();
+        }
+    
+    }
+
+}
diff --git a/clea/clea-client/src/main/java/fr/gouv/clea/client/utils/HttpClientWrapper.java b/clea/clea-client/src/main/java/fr/gouv/clea/client/utils/HttpClientWrapper.java
new file mode 100644
index 0000000000000000000000000000000000000000..8a8fe6049cf0ca4d953eff3a9e35c49d1ac4fa95
--- /dev/null
+++ b/clea/clea-client/src/main/java/fr/gouv/clea/client/utils/HttpClientWrapper.java
@@ -0,0 +1,35 @@
+package fr.gouv.clea.client.utils;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+public class HttpClientWrapper {
+    private HttpClient client;
+
+    public HttpClientWrapper(){
+        this.client = HttpClient.newHttpClient();
+    }
+
+    public <T> T get(String uri, Class<T> returnType) throws IOException, InterruptedException{
+        HttpRequest request = HttpRequest.newBuilder(URI.create(uri)).GET().build();
+        HttpResponse<String> response =  this.client.send(request, HttpResponse.BodyHandlers.ofString());
+        return (T) new ObjectMapper().readValue(response.body(), returnType);
+    } 
+    
+    public <T> T post(String uri, String body, Class<T> returnType) throws IOException, InterruptedException{
+        HttpRequest request = HttpRequest.newBuilder(URI.create(uri)).POST(HttpRequest.BodyPublishers.ofString(body)).build();
+        HttpResponse<String> response =  this.client.send(request, HttpResponse.BodyHandlers.ofString());
+        return (T) new ObjectMapper().readValue(response.body(), returnType);
+    }
+
+    public int postStatusCode(String uri, String body) throws IOException, InterruptedException{
+        HttpRequest request = HttpRequest.newBuilder(URI.create(uri)).POST(HttpRequest.BodyPublishers.ofString(body)).build();
+        HttpResponse<Void> response =  this.client.send(request, HttpResponse.BodyHandlers.discarding());
+        return response.statusCode();
+    }
+}
diff --git a/clea/clea-client/src/main/resources/application.properties b/clea/clea-client/src/main/resources/application.properties
new file mode 100644
index 0000000000000000000000000000000000000000..716aa48829636c9d4362d0111c86a21cf88a126a
--- /dev/null
+++ b/clea/clea-client/src/main/resources/application.properties
@@ -0,0 +1,8 @@
+#Client Component Properties
+backend_url=http://localhost:8080/api/v1
+status_path=./clusters/
+index_filename=index.json
+report_path=/report
+dup_scan_threshold=10800
+duration_unit=3600
+qrprefix=https://tac.gouv.fr/
\ No newline at end of file
diff --git a/clea/clea-client/src/test/java/fr/gouv/clea/client/LocalListTest.java b/clea/clea-client/src/test/java/fr/gouv/clea/client/LocalListTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..86f2b120da7ad73d65ade127b198e9e3c36ed54d
--- /dev/null
+++ b/clea/clea-client/src/test/java/fr/gouv/clea/client/LocalListTest.java
@@ -0,0 +1,69 @@
+package fr.gouv.clea.client;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.time.Instant;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import fr.gouv.clea.client.model.ScannedQrCode;
+import fr.gouv.clea.client.service.CleaClient;
+
+/**
+ * Basic Unit Test for Client
+ */
+public class LocalListTest 
+{
+    private final String prefix = "https://tac.gouv.fr/";
+    private final String qrCode = "AKSYrwI8hzHkrJv0mf9X3I3a3cz8wvP/zQQZ/uD2cL78m5hBXXW46YrPPTxiYNShhQDvyd6w0zyJD96D0tIy6DIRyQOEuWWxW84GmrMDgiOxCFtWt+qlY1Wnsh1szt4UJpCjkYEf7Ij78n/cEQY=";
+    private final String qrCode2 = "AKSYrwI8hzHkrJv0mf9X3I0KXTn4TUzSX7aM4pfWCpsb7CPSLULz1FBWh9+7RP0hU0VxTb15uDJXY61itwy9yJzDbkz8FGXUZra0LBwCg3D8EbSZsBk/g/havNababZULUxXs8IEaMaims2BnOY=";
+    private final String tlId = "pJivAjyHMeSsm/SZ/1fcjQ==";
+    private final Instant now = Instant.now(); 
+    
+    /**
+     * Test : simulating Scanning a QR code and saving it to the LocalList
+     */
+    @Test
+    public void shouldAddScannedQrCode()
+    {
+        CleaClient cleaClient = new CleaClient();
+        
+        cleaClient.scanQrCode(prefix.concat(qrCode), now.getEpochSecond());
+        
+        List<ScannedQrCode> localList = cleaClient.getLocalList();
+        assertThat(localList.size()).isEqualTo(1);
+        ScannedQrCode scanned = localList.get(0);
+        assertThat(scanned.getQrCode()).isEqualTo(qrCode);
+        assertThat(scanned.getScanTime()).isEqualTo(now.getEpochSecond());
+        assertThat(scanned.getLocationTemporaryId()).isEqualTo(tlId);
+    }
+
+     /**
+     * Test : scanning a qr code with the same Tlid before DUPTHRESHOLD second should not be added to the list
+     */
+    @Test
+    public void shouldNotAddTwice(){
+        CleaClient cleaClient = new CleaClient();
+        
+        cleaClient.scanQrCode(prefix.concat(qrCode), now.getEpochSecond());
+        cleaClient.scanQrCode(prefix.concat(qrCode2),now.plusSeconds(3600).getEpochSecond());
+        
+        List<ScannedQrCode> localList = cleaClient.getLocalList();
+        assertThat(localList.size()).isEqualTo(1);
+    }
+
+     /**
+     * Test : scanning a qr code with the same Tlid after DUPTHRESHOLD second should be added to the list
+     */
+    @Test
+    public void shouldAddTwice(){
+        CleaClient cleaClient = new CleaClient();
+        
+        cleaClient.scanQrCode(prefix.concat(qrCode), now.getEpochSecond());
+        cleaClient.scanQrCode(prefix.concat(qrCode2),now.plusSeconds(4*3600).getEpochSecond());
+        
+        List<ScannedQrCode> localList = cleaClient.getLocalList();
+        assertThat(localList.size()).isEqualTo(2);
+    }
+}
diff --git a/clea/clea-client/src/test/java/fr/gouv/clea/client/service/ReportServiceTest.java b/clea/clea-client/src/test/java/fr/gouv/clea/client/service/ReportServiceTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..9b736a9d27d8dfbd50f2e6db1448403ea3f7ca63
--- /dev/null
+++ b/clea/clea-client/src/test/java/fr/gouv/clea/client/service/ReportServiceTest.java
@@ -0,0 +1,48 @@
+package fr.gouv.clea.client.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.io.IOException;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import fr.gouv.clea.client.configuration.CleaClientConfiguration;
+import fr.gouv.clea.client.model.ReportResponse;
+import fr.gouv.clea.client.model.ScannedQrCode;
+import fr.gouv.clea.client.utils.HttpClientWrapper;
+
+public class ReportServiceTest {
+    private final String qrCode = "AKSYrwI8hzHkrJv0mf9X3I3a3cz8wvP/zQQZ/uD2cL78m5hBXXW46YrPPTxiYNShhQDvyd6w0zyJD96D0tIy6DIRyQOEuWWxW84GmrMDgiOxCFtWt+qlY1Wnsh1szt4UJpCjkYEf7Ij78n/cEQY=";
+    private final String qrCode2 = "AAXpe5EhZz3nv3hF8TtpMguUdtQ3lwlpUG7rG0lu3RtbKJlIIiTpHBllKCkLyrpbRcGTXBtfc3GlO3WsRSxyeBT3ngqYI8sgh7lIMDADHzLI5/V3mf/OiYjOLwurVedWzrrCUG2wkLr8Pc2WuAM=";
+    private final Instant now = Instant.ofEpochSecond(3824820600L);
+    private ReportService backend;
+    private List<ScannedQrCode> localList;
+
+    @BeforeEach
+    public void setup() throws IOException, InterruptedException {
+        CleaClientConfiguration config = CleaClientConfiguration.getInstance();
+        HttpClientWrapper httpClient = mock(HttpClientWrapper.class);
+        backend = new ReportService(config.getBackendUrl() + config.getReportPath(), httpClient);
+        when(httpClient.post(anyString(),anyString(),any())).thenReturn(new ReportResponse(true, ""));
+        localList = new ArrayList<>();
+    }
+
+    @Test
+    public void testCanReportInfectedVisits() throws Exception {
+        localList.add(new ScannedQrCode(qrCode, now.getEpochSecond() - 200));
+        localList.add(new ScannedQrCode(qrCode2, now.getEpochSecond()));
+
+        ReportResponse response = backend.report(localList);
+
+        assertThat(response.isSuccess()).isTrue();
+        assertThat(response.getMessage()).isEmpty();
+    }
+}
diff --git a/clea/clea-client/src/test/java/fr/gouv/clea/client/service/StatusServiceTest.java b/clea/clea-client/src/test/java/fr/gouv/clea/client/service/StatusServiceTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..30f13d60152939b8dcf352d043fa7c6ea2d08ab2
--- /dev/null
+++ b/clea/clea-client/src/test/java/fr/gouv/clea/client/service/StatusServiceTest.java
@@ -0,0 +1,44 @@
+package fr.gouv.clea.client.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import fr.gouv.clea.client.model.ScannedQrCode;
+
+public class StatusServiceTest {
+    private final String qrCode = "AKSYrwI8hzHkrJv0mf9X3I3a3cz8wvP/zQQZ/uD2cL78m5hBXXW46YrPPTxiYNShhQDvyd6w0zyJD96D0tIy6DIRyQOEuWWxW84GmrMDgiOxCFtWt+qlY1Wnsh1szt4UJpCjkYEf7Ij78n/cEQY=";
+    private final String qrCode2 = "AAXpe5EhZz3nv3hF8TtpMguUdtQ3lwlpUG7rG0lu3RtbKJlIIiTpHBllKCkLyrpbRcGTXBtfc3GlO3WsRSxyeBT3ngqYI8sgh7lIMDADHzLI5/V3mf/OiYjOLwurVedWzrrCUG2wkLr8Pc2WuAM=";
+    private final Instant now = Instant.ofEpochSecond(3824820600L);
+    private ScannedQrCode qr;
+    private ScannedQrCode qr2;
+
+    @BeforeEach
+    public void setup(){
+        qr = new ScannedQrCode(qrCode, now.getEpochSecond());
+        qr2 = new ScannedQrCode(qrCode2, now.getEpochSecond());
+    }
+    
+    @Test
+    public void statusShouldReturnAtRisk() throws Exception {
+        List<ScannedQrCode> localList = new ArrayList<>();
+        localList.add(qr);
+        StatusService statusService = new StatusService();
+        assertThat(statusService.status(localList)).isGreaterThan(0f);
+    }
+
+    @Test
+    public void statusShouldReturnNoRisk() throws Exception {
+        List<ScannedQrCode> localList = new ArrayList<>();
+        localList.add(qr2);
+        StatusService statusService = new StatusService();
+        assertThat(statusService.status(localList)).isEqualTo(0f);
+    }
+}
+
+
diff --git a/clea/clea-qr-simulator/pom.xml b/clea/clea-qr-simulator/pom.xml
new file mode 100644
index 0000000000000000000000000000000000000000..6b6e103fb902be5229707ad44c97b341d0f48d6b
--- /dev/null
+++ b/clea/clea-qr-simulator/pom.xml
@@ -0,0 +1,41 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+	xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+	<modelVersion>4.0.0</modelVersion>
+	<parent>
+		<groupId>fr.gouv.clea</groupId>
+		<artifactId>clea-server</artifactId>
+		<version>0.1-SNAPSHOT</version>
+	</parent>
+
+	<artifactId>clea-qr-simulator</artifactId>
+	<name>qr-simulator</name>
+	<description>Simulator for QR code generation for Clea (static and dynamic generation)</description>
+
+	<dependencies>
+			<dependency>
+				<groupId>org.junit.jupiter</groupId>
+				<artifactId>junit-jupiter</artifactId>
+				<scope>test</scope>
+			</dependency>
+			<dependency>
+				<groupId>com.fasterxml.jackson.core</groupId>
+				<artifactId>jackson-databind</artifactId>
+			</dependency>
+			<dependency>
+				<groupId>org.projectlombok</groupId>
+				<artifactId>lombok</artifactId>
+				<scope>provided</scope>
+			</dependency>
+			<dependency>
+				<groupId>org.assertj</groupId>
+				<artifactId>assertj-core</artifactId>
+				<scope>test</scope>
+			</dependency>	
+			<dependency>
+				<groupId>fr.inria.clea</groupId>
+				<artifactId>clea-crypto</artifactId>
+			</dependency>
+	</dependencies>
+</project>
diff --git a/clea/clea-qr-simulator/src/main/java/fr/gouv/tacw/qr/LocationQrCodeGenerator.java b/clea/clea-qr-simulator/src/main/java/fr/gouv/tacw/qr/LocationQrCodeGenerator.java
new file mode 100644
index 0000000000000000000000000000000000000000..2a44c8261ceb1a7495b891841dc4944825f4a25e
--- /dev/null
+++ b/clea/clea-qr-simulator/src/main/java/fr/gouv/tacw/qr/LocationQrCodeGenerator.java
@@ -0,0 +1,204 @@
+package fr.gouv.tacw.qr;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+import fr.gouv.tacw.qr.model.QRCode;
+import fr.inria.clea.lsp.CleaEncryptionException;
+import fr.inria.clea.lsp.Location;
+import fr.inria.clea.lsp.LocationContact;
+import fr.inria.clea.lsp.LocationSpecificPart;
+import fr.inria.clea.lsp.Location.LocationBuilder;
+import fr.inria.clea.lsp.utils.TimeUtils;
+import lombok.Builder;
+
+public class LocationQrCodeGenerator {
+    private final long qrCodeRenewalInterval;
+    private final long periodDuration;
+    private final Location location;
+    private long periodStartTime;
+    private List<QRCode> generatedQRs;
+
+    @Builder
+    private LocationQrCodeGenerator(Instant periodStartTime, int periodDuration,
+            int qrCodeRenewalIntervalExponentCompact, boolean staff, int countryCode, int venueType, int venueCategory1,
+            int venueCategory2, String manualContactTracingAuthorityPublicKey, String serverAuthorityPublicKey,
+            String permanentLocationSecretKey, String locationPhone, String locationPin)
+            throws CleaEncryptionException {
+        // TODO: check data validity (eg. < periodDuration <= 255)
+        if (qrCodeRenewalIntervalExponentCompact == 0x1F)
+            this.qrCodeRenewalInterval = 0;
+        else
+            this.qrCodeRenewalInterval = 1 << qrCodeRenewalIntervalExponentCompact;
+        this.periodDuration = periodDuration * TimeUtils.NB_SECONDS_PER_HOUR;
+        this.periodStartTime = TimeUtils.hourRoundedTimestamp(TimeUtils.ntpTimestampFromInstant(periodStartTime));
+
+        LocationSpecificPart locationSpecificPart = this.createLocationSpecificPart(staff, periodDuration,
+                qrCodeRenewalIntervalExponentCompact, countryCode, venueType, venueCategory1, venueCategory2);
+        LocationContact contact = this.createLocationContact(this.periodStartTime, locationPhone, locationPin);
+        this.location = this.createLocation(locationSpecificPart, contact, manualContactTracingAuthorityPublicKey,
+                serverAuthorityPublicKey, permanentLocationSecretKey);
+        this.generatedQRs = new ArrayList<>();
+        this.generateQRCode(this.periodStartTime);
+
+    }
+
+    private Location createLocation(LocationSpecificPart lsp, LocationContact contact,
+            String manualContactTracingAuthorityPublicKey, String serverAuthorityPublicKey,
+            String permanentLocationSecretKey) {
+        LocationBuilder locationBuilder = Location.builder()
+                .locationSpecificPart(lsp)
+                .manualContactTracingAuthorityPublicKey(manualContactTracingAuthorityPublicKey)
+                .serverAuthorityPublicKey(serverAuthorityPublicKey)
+                .permanentLocationSecretKey(permanentLocationSecretKey);
+        if (Objects.nonNull(contact)) {
+            locationBuilder.contact(contact);
+        }
+        return locationBuilder.build();
+    }
+
+    private LocationSpecificPart createLocationSpecificPart(boolean staff, int periodDuration,
+            int qrCodeRenewalIntervalExponentCompact, int countryCode, int venueType, int venueCategory1,
+            int venueCategory2) {
+        LocationSpecificPart lsp = LocationSpecificPart.builder()
+                .staff(staff)
+                .periodDuration(periodDuration)
+                .countryCode(countryCode)
+                .qrCodeRenewalIntervalExponentCompact(qrCodeRenewalIntervalExponentCompact)
+                .venueType(venueType)
+                .venueCategory1(venueCategory1)
+                .venueCategory2(venueCategory2)
+                .build();
+        return lsp;
+    }
+
+    private LocationContact createLocationContact(long periodStartTime, String locationPhone, String locationPin) {
+        LocationContact contact = null;
+        if (locationPhone != null && locationPin != null) {
+            contact = LocationContact.builder()
+                    .locationPhone(locationPhone)
+                    .locationPin(locationPin)
+                    .periodStartTime((int) periodStartTime)
+                    .build();
+        }
+        return contact;
+    }
+
+    /**
+     * find a valid QRCode for the provided timestamp in the already generated one.
+     * 
+     * @param timestamp timestamp at which the QRCode should be valid.
+     * @return a valid QRCode or null if none were found.
+     */
+    public QRCode findExistingQrCode(long timestamp) {
+        for (QRCode qr : generatedQRs) {
+            if (qr.isValidScanTime(timestamp)) {
+                return qr;
+            }
+        }
+        return null;
+    }
+
+    /**
+     * find a valid QRCode for the provided timestamp in the already generated one.
+     * 
+     * @param instant instant at which the QRCode should be valid
+     * @return a valid QRCode or null if none were found.
+     */
+    public QRCode findExistingQrCode(Instant instant) {
+        return this.findExistingQrCode(TimeUtils.ntpTimestampFromInstant(instant));
+    }
+
+    private QRCode generateQRCode(long timestamp) throws CleaEncryptionException {
+        QRCode qr;
+        if (this.qrCodeRenewalInterval == 0) {
+            String deepLink = this.location.newDeepLink((int) this.periodStartTime);
+            qr = new QRCode(deepLink, this.periodStartTime, this.qrCodeRenewalInterval);
+        } else {
+            timestamp = timestamp - ((timestamp - this.periodStartTime) % this.qrCodeRenewalInterval);
+            String deepLink = this.location.newDeepLink((int) this.periodStartTime, (int) timestamp);
+            qr = new QRCode(deepLink, timestamp, this.qrCodeRenewalInterval);
+        }
+        this.generatedQRs.add(qr);
+        return qr;
+    }
+
+    /**
+     * get a valid QR code for the provided NTP timestamp
+     * 
+     * @param timestamp timestamp at which the returned QRCode should be valid
+     * @return a valid QRCode for the provided timestamp
+     * @throws InvalidTimestampException
+     * @throws CleaEncryptionException
+     */
+    public QRCode getQrCodeAt(long timestamp) throws InvalidTimestampException, CleaEncryptionException {
+        if (timestamp < this.periodStartTime || timestamp > this.periodStartTime + this.periodDuration) {
+            throw new InvalidTimestampException("timestamp : " + timestamp
+                    + " not in the current period of the generator. consider starting a new period first.\n");
+        }
+        // check if qr already exists for timestamp
+        QRCode qr = this.findExistingQrCode(timestamp);
+        if (Objects.nonNull(qr)) {
+            return qr;
+        }
+        // generate a new one
+        qr = this.generateQRCode(timestamp);
+        return qr;
+    }
+
+    /**
+     * start a new period and return its first valid QRCode
+     * 
+     * @param periodStart NTP timestamp at which the period started.
+     * @return a QRCode with the provided timestamp (rounded to the nearest hour) as
+     *         starting validity
+     * @throws InvalidTimestampException
+     * @throws CleaEncryptionException
+     */
+    public QRCode startNewPeriod(long periodStart) throws InvalidTimestampException, CleaEncryptionException {
+        this.periodStartTime = TimeUtils.hourRoundedTimestamp(periodStart);
+        this.generatedQRs.clear();
+        return this.generateQRCode(periodStart);
+    }
+
+    /**
+     * start a new period and return its first valid QRCode
+     * 
+     * @param instant : instant at which the new period should start, will be
+     *                rounded to the nearest hour.
+     * @return a QRCode with the provided instant (rounded to the nearest hour) as
+     *         starting validity
+     * @throws InvalidTimestampException
+     * @throws CleaEncryptionException
+     */
+    public QRCode startNewPeriod(Instant instant) throws InvalidTimestampException, CleaEncryptionException {
+        return this.startNewPeriod(TimeUtils.ntpTimestampFromInstant(instant));
+    }
+
+    /**
+     * get a valid QR code for the provided instant
+     * 
+     * @param instant : instant at which the returned QRCode should be valid
+     * @return a valid QRCode for the provided instant
+     * @throws InvalidTimestampException if instant is not included in the current
+     *                                   period
+     * @throws CleaEncryptionException
+     */
+    public QRCode getQrCodeAt(Instant instant) throws InvalidTimestampException, CleaEncryptionException {
+        return this.getQrCodeAt(TimeUtils.ntpTimestampFromInstant(instant));
+    }
+
+    public long getPeriodStart() {
+        return this.periodStartTime;
+    }
+
+    public static class InvalidTimestampException extends IllegalArgumentException {
+        private static final long serialVersionUID = 1L;
+    
+        public InvalidTimestampException(String msg) {
+            super(msg);
+        }
+    }
+}
diff --git a/clea/clea-qr-simulator/src/main/java/fr/gouv/tacw/qr/model/QRCode.java b/clea/clea-qr-simulator/src/main/java/fr/gouv/tacw/qr/model/QRCode.java
new file mode 100644
index 0000000000000000000000000000000000000000..6043a19f6cfb9f518b7acbd8c6010f6b1c0ee373
--- /dev/null
+++ b/clea/clea-qr-simulator/src/main/java/fr/gouv/tacw/qr/model/QRCode.java
@@ -0,0 +1,31 @@
+package fr.gouv.tacw.qr.model;
+
+import java.util.Arrays;
+import java.util.Base64;
+
+import fr.inria.clea.lsp.Location;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@AllArgsConstructor
+@NoArgsConstructor
+public class QRCode {
+    private String qrCode;
+    private long qrCodeValidityStartTime;
+    private long qrCodeRenewalInterval;
+
+    public boolean isValidScanTime(long timestamp){
+        if( this.qrCodeRenewalInterval > 0)
+            return (timestamp >= qrCodeValidityStartTime) && (timestamp <= qrCodeValidityStartTime + qrCodeRenewalInterval);
+        else
+            return (timestamp >= qrCodeValidityStartTime);
+    }
+
+    public String getLocationTemporaryPublicID(){
+        byte[] locationTemporaryPublicIDByte = Arrays.copyOfRange(Base64.getDecoder().decode(this.qrCode.substring(Location.COUNTRY_SPECIFIC_PREFIX.length())), 1, 17) ;
+        return Base64.getEncoder().encodeToString(locationTemporaryPublicIDByte);
+    }
+
+}
diff --git a/clea/clea-qr-simulator/src/test/java/fr/gouv/tacw/qr/LocationQrCodeGeneratorTest.java b/clea/clea-qr-simulator/src/test/java/fr/gouv/tacw/qr/LocationQrCodeGeneratorTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..169e42da5a7033bc53ab0ddbabfa959f4ab5d392
--- /dev/null
+++ b/clea/clea-qr-simulator/src/test/java/fr/gouv/tacw/qr/LocationQrCodeGeneratorTest.java
@@ -0,0 +1,89 @@
+package fr.gouv.tacw.qr;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.time.Instant;
+
+import org.junit.jupiter.api.Test;
+
+import fr.gouv.tacw.qr.model.QRCode;
+
+import org.junit.jupiter.api.BeforeEach;
+
+public class LocationQrCodeGeneratorTest {
+    
+    private static final String manualContactTracingAuthorityPublicKey  = "04bd776a941090db1c90057401043babafc77164efedad1cbfbab2edec53c5afaff718a33e4cc8f2e9514b162dd4700e517ad341e80f47d49dc0b7e70b30ca4781";
+    private static final String permanentLocationSecretKey              = "2d576fddb3b721ef86c1512f1ed95452faa5ec6faba0c7a226ad2ac050ed6d49";
+    private static final String serverAuthorityPublicKey                = "04bd776a941090db1c90057401043babafc77164efedad1cbfbab2edec53c5afaff718a33e4cc8f2e9514b162dd4700e517ad341e80f47d49dc0b7e70b30ca4781";
+    private static final Instant now = Instant.now();
+    private LocationQrCodeGenerator staticGenerator;
+    private LocationQrCodeGenerator dynamicGenerator;
+
+    @BeforeEach
+    public void setUp() throws Exception{
+        staticGenerator = LocationQrCodeGenerator.builder()
+                                        .countryCode(250)
+                                        .staff(false)
+                                        .periodStartTime(now)
+                                        .periodDuration(8)
+                                        .venueCategory1(0)
+                                        .venueCategory2(1)
+                                        .venueType(3)
+                                        .qrCodeRenewalIntervalExponentCompact(0x1F)
+                                        .manualContactTracingAuthorityPublicKey(manualContactTracingAuthorityPublicKey)
+                                        .permanentLocationSecretKey(permanentLocationSecretKey)
+                                        .serverAuthorityPublicKey(serverAuthorityPublicKey)
+                                        .locationPhone("0123456789")
+                                        .locationPin("1234")
+                                        .build();
+        dynamicGenerator = LocationQrCodeGenerator.builder()
+                                        .countryCode(250)
+                                        .staff(false)
+                                        .periodStartTime(now)
+                                        .periodDuration(8)
+                                        .venueCategory1(0)
+                                        .venueCategory2(1)
+                                        .venueType(3)
+                                        .qrCodeRenewalIntervalExponentCompact(10)
+                                        .manualContactTracingAuthorityPublicKey(manualContactTracingAuthorityPublicKey)
+                                        .permanentLocationSecretKey(permanentLocationSecretKey)
+                                        .serverAuthorityPublicKey(serverAuthorityPublicKey)
+                                        .locationPhone("0123456789")
+                                        .locationPin("1234")
+                                        .build();
+    }
+
+    @Test
+    public void shouldGenerateQR() throws Exception{
+        QRCode qr = staticGenerator.getQrCodeAt(staticGenerator.getPeriodStart());
+        assertThat(qr.getQrCode()).isNotEmpty();
+    }
+
+    @Test
+    public void shouldGenerateOnlyOnce() throws Exception{
+        QRCode qr = staticGenerator.getQrCodeAt(staticGenerator.getPeriodStart());
+        QRCode qr2 = staticGenerator.getQrCodeAt(staticGenerator.getPeriodStart()+600);
+        assertThat(qr2).isEqualTo(qr);
+        
+        qr = dynamicGenerator.getQrCodeAt(dynamicGenerator.getPeriodStart());
+        qr2 = dynamicGenerator.getQrCodeAt(dynamicGenerator.getPeriodStart()+600);
+        assertThat(qr2).isEqualTo(qr);
+    }
+    
+    @Test
+    public void shouldBeSameTLid() throws Exception{
+        QRCode qr = dynamicGenerator.getQrCodeAt(dynamicGenerator.getPeriodStart());
+        QRCode qr2 = dynamicGenerator.getQrCodeAt(dynamicGenerator.getPeriodStart()+3600);
+
+        assertThat(qr.getLocationTemporaryPublicID()).isEqualTo(qr2.getLocationTemporaryPublicID());
+
+    }
+
+    @Test
+    public void startingNewPeriod() throws Exception{
+        QRCode qr = dynamicGenerator.getQrCodeAt(dynamicGenerator.getPeriodStart());
+        QRCode qr2 = dynamicGenerator.startNewPeriod(now.plusSeconds(3600*24));
+        assertThat(qr).isNotEqualTo(qr2);
+        assertThat(qr.getLocationTemporaryPublicID()).isNotEqualTo(qr2.getLocationTemporaryPublicID());
+    }
+}
diff --git a/clea/clea-venue-consumer/Dockerfile b/clea/clea-venue-consumer/Dockerfile
new file mode 100644
index 0000000000000000000000000000000000000000..07aeefec0d181310ad26ef579da285b905f03ab7
--- /dev/null
+++ b/clea/clea-venue-consumer/Dockerfile
@@ -0,0 +1,10 @@
+FROM azul/zulu-openjdk-alpine:11
+
+RUN adduser -D javaapp
+
+COPY ./target/*-exec.jar /home/javaapp/clea-venue-consumer.jar
+
+WORKDIR /home/javaapp
+
+ENTRYPOINT ["java","-jar","clea-venue-consumer.jar"]
+EXPOSE 8080
diff --git a/clea/clea-venue-consumer/pom.xml b/clea/clea-venue-consumer/pom.xml
new file mode 100644
index 0000000000000000000000000000000000000000..d829948630fe882d77ff4bc9b09ce68e0a70a634
--- /dev/null
+++ b/clea/clea-venue-consumer/pom.xml
@@ -0,0 +1,127 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+
+    <modelVersion>4.0.0</modelVersion>
+
+    <parent>
+        <groupId>fr.gouv.clea</groupId>
+        <artifactId>clea-server</artifactId>
+        <version>0.1-SNAPSHOT</version>
+    </parent>
+
+    <artifactId>clea-venue-consumer</artifactId>
+    <name>clea-venue-consumer</name>
+    <description>Clea (Cluster Exposure Verification) Venue consumer used to decode, verify and store infected visits.
+    </description>
+
+    <dependencies>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-data-jpa</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.h2database</groupId>
+            <artifactId>h2</artifactId>
+            <scope>test</scope><!--  change to runtime to use h2 db (with local profile) -->
+        </dependency>
+        <dependency>
+            <groupId>org.postgresql</groupId>
+            <artifactId>postgresql</artifactId>
+            <scope>runtime</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.kafka</groupId>
+            <artifactId>spring-kafka</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.kafka</groupId>
+            <artifactId>spring-kafka-test</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.testcontainers</groupId>
+            <artifactId>kafka</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>fr.inria.clea</groupId>
+            <artifactId>clea-crypto</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-web</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-actuator</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.projectlombok</groupId>
+            <artifactId>lombok</artifactId>
+            <optional>true</optional>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-test</artifactId>
+            <scope>test</scope>
+            <exclusions>
+                <exclusion>
+                    <groupId>org.junit.vintage</groupId>
+                    <artifactId>junit-vintage-engine</artifactId>
+                </exclusion>
+            </exclusions>
+        </dependency>
+        <dependency>
+            <groupId>io.micrometer</groupId>
+            <artifactId>micrometer-core</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>io.micrometer</groupId>
+            <artifactId>micrometer-registry-prometheus</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.cloud</groupId>
+            <artifactId>spring-cloud-starter-bootstrap</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.cloud</groupId>
+            <artifactId>spring-cloud-vault-config-consul</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.cloud</groupId>
+            <artifactId>spring-cloud-starter-consul-config</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-configuration-processor</artifactId>
+            <optional>true</optional>
+        </dependency>
+        <dependency>
+            <groupId>org.mockito</groupId>
+            <artifactId>mockito-core</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.assertj</groupId>
+            <artifactId>assertj-core</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.awaitility</groupId>
+            <artifactId>awaitility</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.commons</groupId>
+            <artifactId>commons-lang3</artifactId>
+        </dependency>
+    </dependencies>
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.springframework.boot</groupId>
+                <artifactId>spring-boot-maven-plugin</artifactId>
+            </plugin>
+        </plugins>
+    </build>
+</project>
\ No newline at end of file
diff --git a/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/CleaVenueConsumerApplication.java b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/CleaVenueConsumerApplication.java
new file mode 100644
index 0000000000000000000000000000000000000000..6d289309759818c45bb0c1db7968e83f53a40f9e
--- /dev/null
+++ b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/CleaVenueConsumerApplication.java
@@ -0,0 +1,12 @@
+package fr.gouv.clea.consumer;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication(scanBasePackages = "fr.gouv.clea.consumer")
+public class CleaVenueConsumerApplication {
+
+    public static void main(String[] args) {
+        SpringApplication.run(CleaVenueConsumerApplication.class, args);
+    }
+}
diff --git a/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/configuration/SchedulingConfiguration.java b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/configuration/SchedulingConfiguration.java
new file mode 100644
index 0000000000000000000000000000000000000000..47a01fdc0640edfbec9aeaad88f68a0712f6039b
--- /dev/null
+++ b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/configuration/SchedulingConfiguration.java
@@ -0,0 +1,11 @@
+package fr.gouv.clea.consumer.configuration;
+
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.annotation.EnableScheduling;
+
+@ConditionalOnProperty(value = "clea.conf.scheduling.purge.enabled", havingValue = "true")
+@Configuration
+@EnableScheduling
+public class SchedulingConfiguration {
+}
diff --git a/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/configuration/SecurityConfiguration.java b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/configuration/SecurityConfiguration.java
new file mode 100644
index 0000000000000000000000000000000000000000..d44a08d7af9ab4e7a6715b44c911887a25da4682
--- /dev/null
+++ b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/configuration/SecurityConfiguration.java
@@ -0,0 +1,30 @@
+package fr.gouv.clea.consumer.configuration;
+
+import fr.inria.clea.lsp.CleaEciesEncoder;
+import fr.inria.clea.lsp.LocationSpecificPartDecoder;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+public class SecurityConfiguration {
+
+    private final String serverAuthoritySecretKey;
+
+    @Autowired
+    public SecurityConfiguration(
+            @Value("${clea.conf.security.crypto.manualCTAuthoritySecretKey}") String serverAuthoritySecretKey) {
+        this.serverAuthoritySecretKey = serverAuthoritySecretKey;
+    }
+
+    @Bean
+    public LocationSpecificPartDecoder getLocationSpecificPartDecoder() {
+        return new LocationSpecificPartDecoder(serverAuthoritySecretKey);
+    }
+
+    @Bean
+    public CleaEciesEncoder getCleaEciesEncoder() {
+        return new CleaEciesEncoder();
+    }
+}
diff --git a/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/model/DecodedVisit.java b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/model/DecodedVisit.java
new file mode 100644
index 0000000000000000000000000000000000000000..49f5664726481cbd51b7ba63b3a0c96678d3a321
--- /dev/null
+++ b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/model/DecodedVisit.java
@@ -0,0 +1,23 @@
+package fr.gouv.clea.consumer.model;
+
+import fr.inria.clea.lsp.EncryptedLocationSpecificPart;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.ToString;
+
+import java.time.Instant;
+
+@AllArgsConstructor
+@Builder
+@Getter
+@ToString
+public class DecodedVisit {
+    private final Instant qrCodeScanTime; // t_qrScan
+    private final EncryptedLocationSpecificPart encryptedLocationSpecificPart;
+    private final boolean isBackward;
+
+    public String getStringLocationTemporaryPublicId() {
+        return this.encryptedLocationSpecificPart.getLocationTemporaryPublicId().toString();
+    }
+}
diff --git a/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/model/ExposedVisitEntity.java b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/model/ExposedVisitEntity.java
new file mode 100644
index 0000000000000000000000000000000000000000..2695d022c6ece2af1034fbc76da822b061b81634
--- /dev/null
+++ b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/model/ExposedVisitEntity.java
@@ -0,0 +1,55 @@
+package fr.gouv.clea.consumer.model;
+
+import java.time.Instant;
+import java.util.UUID;
+
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.GeneratedValue;
+import javax.persistence.Id;
+import javax.persistence.Table;
+
+import org.hibernate.annotations.CreationTimestamp;
+import org.hibernate.annotations.GenericGenerator;
+import org.hibernate.annotations.Type;
+import org.hibernate.annotations.UpdateTimestamp;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@Entity
+@Table(name = "EXPOSED_VISITS")
+public class ExposedVisitEntity {
+
+    @Id
+    @GeneratedValue(generator = "uuid")
+    @GenericGenerator(name = "uuid", strategy = "org.hibernate.id.UUIDGenerator")
+    private String id;
+
+    @Column(name="LTId")
+    @Type(type="pg-uuid")
+    private UUID locationTemporaryPublicId;
+    
+    private int venueType;
+    private int venueCategory1;
+    private int venueCategory2;
+    private long periodStart;
+    @Column(name="timeslot")
+    private int timeSlot;
+    private long backwardVisits;
+    private long forwardVisits;
+
+    private Instant qrCodeScanTime; // for purge
+
+    @CreationTimestamp
+    private Instant createdAt; // for db ops/maintenance
+
+    @UpdateTimestamp
+    private Instant updatedAt; // for db ops/maintenance
+}
diff --git a/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/model/Visit.java b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/model/Visit.java
new file mode 100644
index 0000000000000000000000000000000000000000..f46f7eba3c65ad4d1347da200d7b61a908b6c64e
--- /dev/null
+++ b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/model/Visit.java
@@ -0,0 +1,45 @@
+package fr.gouv.clea.consumer.model;
+
+import fr.inria.clea.lsp.LocationSpecificPart;
+import lombok.Getter;
+import lombok.Setter;
+import lombok.ToString;
+import lombok.experimental.SuperBuilder;
+
+import java.time.Instant;
+
+@SuperBuilder
+@Getter
+@Setter
+@ToString
+public class Visit extends LocationSpecificPart {
+
+    protected Instant qrCodeScanTime;
+    protected boolean isBackward;
+
+    public static Visit from(LocationSpecificPart lsp, DecodedVisit decodedVisit) {
+        return builder()
+                .version(lsp.getVersion())
+                .type(lsp.getType())
+                .countryCode(lsp.getCountryCode())
+                .locationTemporaryPublicId(lsp.getLocationTemporaryPublicId())
+                .qrCodeRenewalIntervalExponentCompact(lsp.getQrCodeRenewalIntervalExponentCompact())
+                .venueType(lsp.getVenueType())
+                .venueCategory1(lsp.getVenueCategory1())
+                .venueCategory2(lsp.getVenueCategory2())
+                .periodDuration(lsp.getPeriodDuration())
+                .compressedPeriodStartTime(lsp.getCompressedPeriodStartTime())
+                .qrCodeValidityStartTime(lsp.getQrCodeValidityStartTime())
+                .locationTemporarySecretKey(lsp.getLocationTemporarySecretKey())
+                .encryptedLocationContactMessage(lsp.getEncryptedLocationContactMessage())
+                .qrCodeScanTime(decodedVisit.getQrCodeScanTime())
+                .isBackward(decodedVisit.isBackward())
+                .build();
+    }
+
+    public String getStringLocationTemporaryPublicId() {
+        return this.getLocationTemporaryPublicId().toString();
+    }
+
+}
+
diff --git a/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/repository/IExposedVisitRepository.java b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/repository/IExposedVisitRepository.java
new file mode 100644
index 0000000000000000000000000000000000000000..c33ec90ebf11d56a831b3ed82d0f12107d497071
--- /dev/null
+++ b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/repository/IExposedVisitRepository.java
@@ -0,0 +1,16 @@
+package fr.gouv.clea.consumer.repository;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.UUID;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import fr.gouv.clea.consumer.model.ExposedVisitEntity;
+
+public interface IExposedVisitRepository extends JpaRepository<ExposedVisitEntity, String> {
+
+    int deleteAllByQrCodeScanTimeBefore(Instant qrCodeScanTime);
+
+    List<ExposedVisitEntity> findAllByLocationTemporaryPublicIdAndPeriodStart(UUID locationTemporaryPublicId, long periodStart);
+}
diff --git a/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/service/IConsumerService.java b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/service/IConsumerService.java
new file mode 100644
index 0000000000000000000000000000000000000000..3e568b618fa6a50da0e0b4fc4833934919877bf3
--- /dev/null
+++ b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/service/IConsumerService.java
@@ -0,0 +1,8 @@
+package fr.gouv.clea.consumer.service;
+
+import fr.gouv.clea.consumer.model.DecodedVisit;
+
+public interface IConsumerService {
+
+    void consume(DecodedVisit decodedVisit);
+}
diff --git a/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/service/IDecodedVisitService.java b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/service/IDecodedVisitService.java
new file mode 100644
index 0000000000000000000000000000000000000000..dc90ffbc971a94e0cfc4e7561e19821ff3dfb3d5
--- /dev/null
+++ b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/service/IDecodedVisitService.java
@@ -0,0 +1,11 @@
+package fr.gouv.clea.consumer.service;
+
+import java.util.Optional;
+
+import fr.gouv.clea.consumer.model.DecodedVisit;
+import fr.gouv.clea.consumer.model.Visit;
+
+public interface IDecodedVisitService {
+
+    Optional<Visit> decryptAndValidate(DecodedVisit decodedVisit);
+}
diff --git a/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/service/IExposedVisitEntityService.java b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/service/IExposedVisitEntityService.java
new file mode 100644
index 0000000000000000000000000000000000000000..d9440896cac6cf7cc707bbf129dd180b30d86118
--- /dev/null
+++ b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/service/IExposedVisitEntityService.java
@@ -0,0 +1,7 @@
+package fr.gouv.clea.consumer.service;
+
+public interface IExposedVisitEntityService {
+
+    void deleteOutdatedExposedVisits();
+
+}
diff --git a/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/service/IVisitExpositionAggregatorService.java b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/service/IVisitExpositionAggregatorService.java
new file mode 100644
index 0000000000000000000000000000000000000000..89c482af554ba4e83fcab078e63163e391ef85c9
--- /dev/null
+++ b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/service/IVisitExpositionAggregatorService.java
@@ -0,0 +1,8 @@
+package fr.gouv.clea.consumer.service;
+
+import fr.gouv.clea.consumer.model.Visit;
+
+public interface IVisitExpositionAggregatorService {
+
+    void updateExposureCount(Visit visit);
+}
diff --git a/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/service/impl/ConsumerService.java b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/service/impl/ConsumerService.java
new file mode 100644
index 0000000000000000000000000000000000000000..154519848900ba88d53ada2e2b1120115f2aa651
--- /dev/null
+++ b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/service/impl/ConsumerService.java
@@ -0,0 +1,38 @@
+package fr.gouv.clea.consumer.service.impl;
+
+import fr.gouv.clea.consumer.model.DecodedVisit;
+import fr.gouv.clea.consumer.model.Visit;
+import fr.gouv.clea.consumer.service.IConsumerService;
+import fr.gouv.clea.consumer.service.IDecodedVisitService;
+import fr.gouv.clea.consumer.service.IVisitExpositionAggregatorService;
+import fr.gouv.clea.consumer.utils.MessageFormatter;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.kafka.annotation.KafkaListener;
+import org.springframework.stereotype.Component;
+
+import java.util.Optional;
+
+@Component
+@Slf4j
+public class ConsumerService implements IConsumerService {
+
+    private final IDecodedVisitService decodedVisitService;
+    private final IVisitExpositionAggregatorService visitExpositionAggregatorService;
+
+    @Autowired
+    public ConsumerService(
+            IDecodedVisitService decodedVisitService,
+            IVisitExpositionAggregatorService visitExpositionAggregatorService) {
+        this.decodedVisitService = decodedVisitService;
+        this.visitExpositionAggregatorService = visitExpositionAggregatorService;
+    }
+
+    @Override
+    @KafkaListener(topics = "${spring.kafka.template.default-topic}")
+    public void consume(DecodedVisit decodedVisit) {
+        log.info("[locationTemporaryPublicId: {}, qrCodeScanTime: {}] retrieved from queue", MessageFormatter.truncateUUID(decodedVisit.getStringLocationTemporaryPublicId()), decodedVisit.getQrCodeScanTime());
+        Optional<Visit> optionalVisit = decodedVisitService.decryptAndValidate(decodedVisit);
+        optionalVisit.ifPresent(visitExpositionAggregatorService::updateExposureCount);
+    }
+}
diff --git a/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/service/impl/DecodedVisitService.java b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/service/impl/DecodedVisitService.java
new file mode 100644
index 0000000000000000000000000000000000000000..c79db9663f2c5e779e8c18a9a9de9206c01e3ba3
--- /dev/null
+++ b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/service/impl/DecodedVisitService.java
@@ -0,0 +1,87 @@
+package fr.gouv.clea.consumer.service.impl;
+
+import fr.gouv.clea.consumer.model.DecodedVisit;
+import fr.gouv.clea.consumer.model.Visit;
+import fr.gouv.clea.consumer.service.IDecodedVisitService;
+import fr.gouv.clea.consumer.utils.MessageFormatter;
+import fr.inria.clea.lsp.CleaEciesEncoder;
+import fr.inria.clea.lsp.CleaEncryptionException;
+import fr.inria.clea.lsp.LocationSpecificPart;
+import fr.inria.clea.lsp.LocationSpecificPartDecoder;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import java.util.Optional;
+import java.util.UUID;
+
+@Component
+@Slf4j
+public class DecodedVisitService implements IDecodedVisitService {
+
+    private final LocationSpecificPartDecoder decoder;
+
+    private final CleaEciesEncoder cleaEciesEncoder;
+
+    private final int driftBetweenDeviceAndOfficialTimeInSecs;
+
+    private final int cleaClockDriftInSecs;
+
+    @Autowired
+    public DecodedVisitService(
+            LocationSpecificPartDecoder decoder,
+            CleaEciesEncoder cleaEciesEncoder,
+            @Value("${clea.conf.driftBetweenDeviceAndOfficialTimeInSecs}") int driftBetweenDeviceAndOfficialTimeInSecs,
+            @Value("${clea.conf.cleaClockDriftInSecs}") int cleaClockDriftInSecs
+    ) {
+        this.decoder = decoder;
+        this.cleaEciesEncoder = cleaEciesEncoder;
+        this.driftBetweenDeviceAndOfficialTimeInSecs = driftBetweenDeviceAndOfficialTimeInSecs;
+        this.cleaClockDriftInSecs = cleaClockDriftInSecs;
+    }
+
+    @Override
+    public Optional<Visit> decryptAndValidate(DecodedVisit decodedVisit) {
+        try {
+            LocationSpecificPart lsp = this.decoder.decrypt(decodedVisit.getEncryptedLocationSpecificPart());
+            Visit visit = Visit.from(lsp, decodedVisit);
+            return this.verify(visit);
+        } catch (Exception e) {
+            log.warn("error decrypting [locationTemporaryPublicId: {}, qrCodeScanTime: {}, message: {}]", MessageFormatter.truncateUUID(decodedVisit.getStringLocationTemporaryPublicId()), decodedVisit.getQrCodeScanTime(), e.getLocalizedMessage());
+            return Optional.empty();
+        }
+    }
+
+    private Optional<Visit> verify(Visit visit) {
+        if (!this.isFresh(visit)) {
+            log.warn("drift check failed for [locationTemporaryPublicId: {}, qrCodeScanTime: {}]", MessageFormatter.truncateUUID(visit.getStringLocationTemporaryPublicId()), visit.getQrCodeScanTime());
+            return Optional.empty();
+        } else if (!this.hasValidTemporaryLocationPublicId(visit)) {
+            log.warn("locationTemporaryPublicId check failed for [locationTemporaryPublicId: {}, qrCodeScanTime: {}]", MessageFormatter.truncateUUID(visit.getStringLocationTemporaryPublicId()), visit.getQrCodeScanTime());
+            return Optional.empty();
+        }
+        return Optional.of(visit);
+    }
+
+    private boolean hasValidTemporaryLocationPublicId(Visit visit) {
+        try {
+            UUID computed = cleaEciesEncoder.computeLocationTemporaryPublicId(visit.getLocationTemporarySecretKey());
+            return computed.equals(visit.getLocationTemporaryPublicId());
+        } catch (CleaEncryptionException e) {
+            log.warn("locationTemporaryPublicId check failed for [locationTemporaryPublicId: {}, qrCodeScanTime: {}]", MessageFormatter.truncateUUID(visit.getStringLocationTemporaryPublicId()), visit.getQrCodeScanTime());
+            return false;
+        }
+    }
+
+    private boolean isFresh(Visit visit) {
+        return true;
+        /*
+        double qrCodeRenewalInterval = (visit.getQrCodeRenewalIntervalExponentCompact() == 0x1F)
+                ? 0 : Math.pow(2, visit.getQrCodeRenewalIntervalExponentCompact());
+        if (qrCodeRenewalInterval == 0)
+            return true;
+        return Math.abs(TimeUtils.ntpTimestampFromInstant(visit.getQrCodeScanTime()) - visit.getQrCodeValidityStartTime()) < (qrCodeRenewalInterval + driftBetweenDeviceAndOfficialTimeInSecs + cleaClockDriftInSecs);
+         */
+    }
+}
diff --git a/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/service/impl/ExposedVisitEntityService.java b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/service/impl/ExposedVisitEntityService.java
new file mode 100644
index 0000000000000000000000000000000000000000..720ed1771384ae93aca33a7442c1dbd6eccb4924
--- /dev/null
+++ b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/service/impl/ExposedVisitEntityService.java
@@ -0,0 +1,44 @@
+package fr.gouv.clea.consumer.service.impl;
+
+import fr.gouv.clea.consumer.repository.IExposedVisitRepository;
+import fr.gouv.clea.consumer.service.IExposedVisitEntityService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import javax.transaction.Transactional;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+
+@Component
+@Slf4j
+public class ExposedVisitEntityService implements IExposedVisitEntityService {
+
+    private final IExposedVisitRepository repository;
+
+    private final int retentionDurationInDays;
+
+    @Autowired
+    public ExposedVisitEntityService(
+            IExposedVisitRepository repository,
+            @Value("${clea.conf.retentionDurationInDays}") int retentionDurationInDays
+    ) {
+        this.repository = repository;
+        this.retentionDurationInDays = retentionDurationInDays;
+    }
+
+    @Override
+    @Transactional
+    @Scheduled(cron = "${clea.conf.scheduling.purge.cron}")
+    public void deleteOutdatedExposedVisits() {
+        try {
+            int count = this.repository.deleteAllByQrCodeScanTimeBefore(Instant.now().minus(retentionDurationInDays, ChronoUnit.DAYS));
+            log.info("successfully purged {} entries from DB", count);
+        } catch (Exception e) {
+            log.error("error during purge");
+            throw e;
+        }
+    }
+}
diff --git a/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/service/impl/VisitExpositionAggregatorService.java b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/service/impl/VisitExpositionAggregatorService.java
new file mode 100644
index 0000000000000000000000000000000000000000..4c78856dd693b39580e5049cb954622e772c1bd4
--- /dev/null
+++ b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/service/impl/VisitExpositionAggregatorService.java
@@ -0,0 +1,102 @@
+package fr.gouv.clea.consumer.service.impl;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+import java.util.stream.Stream;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+import fr.gouv.clea.consumer.model.ExposedVisitEntity;
+import fr.gouv.clea.consumer.model.Visit;
+import fr.gouv.clea.consumer.repository.IExposedVisitRepository;
+import fr.gouv.clea.consumer.service.IVisitExpositionAggregatorService;
+import fr.inria.clea.lsp.utils.TimeUtils;
+import lombok.extern.slf4j.Slf4j;
+
+@Component
+@Slf4j
+public class VisitExpositionAggregatorService implements IVisitExpositionAggregatorService {
+
+    private final static long EXPOSURE_TIME_UNIT = TimeUtils.NB_SECONDS_PER_HOUR;
+    private final IExposedVisitRepository repository;
+
+    @Autowired
+    public VisitExpositionAggregatorService(IExposedVisitRepository repository) {
+        this.repository = repository;
+    }
+
+    @Override
+    public void updateExposureCount(Visit visit) {
+        Instant periodStartInstant = periodStartInstant(visit);
+        long scanTimeSlot = Duration.between(periodStartInstant, visit.getQrCodeScanTime()).toSeconds() / EXPOSURE_TIME_UNIT;
+        int exposureTime = this.getExposureTime(visit.getVenueType(), visit.getVenueCategory1(), visit.getVenueCategory2(), visit.isStaff());
+        int firstExposedSlot = Math.max(0, (int) scanTimeSlot - exposureTime);
+        int lastExposedSlot = Math.min(visit.getPeriodDuration(), (int) scanTimeSlot + exposureTime);
+
+        List<ExposedVisitEntity> exposedVisits = repository.findAllByLocationTemporaryPublicIdAndPeriodStart(visit.getLocationTemporaryPublicId(), periodStartInstant.toEpochMilli());
+
+        List<ExposedVisitEntity> toUpdate = new ArrayList<>();
+        List<ExposedVisitEntity> toPersist = new ArrayList<>();
+
+        IntStream.rangeClosed(Math.min(firstExposedSlot, lastExposedSlot), Math.max(firstExposedSlot, lastExposedSlot))
+                .forEach(slotIndex ->
+                        exposedVisits.stream()
+                                .filter(exposedVisit -> exposedVisit.getTimeSlot() == slotIndex)
+                                .findFirst()
+                                .ifPresentOrElse(
+                                        exposedVisit -> toUpdate.add(this.updateExposedVisit(visit, exposedVisit)),
+                                        () -> toPersist.add(this.newExposedVisit(visit, slotIndex))
+                                )
+                );
+
+        List<ExposedVisitEntity> merged = Stream.concat(toUpdate.stream(), toPersist.stream()).collect(Collectors.toList());
+        if (!merged.isEmpty()) {
+            log.info("Persisting {} new visits!", toPersist.size());
+            log.info("Updating {} existing visits!", toUpdate.size());
+            repository.saveAll(merged);
+        }
+    }
+
+    protected Instant periodStartInstant(Visit visit) {
+        return TimeUtils.instantFromTimestamp((long) visit.getCompressedPeriodStartTime() * TimeUtils.NB_SECONDS_PER_HOUR);
+    }
+
+    protected ExposedVisitEntity updateExposedVisit(Visit visit, ExposedVisitEntity exposedVisit) {
+        if (visit.isBackward()) {
+            exposedVisit.setBackwardVisits(exposedVisit.getBackwardVisits() + 1);
+        } else {
+            exposedVisit.setForwardVisits(exposedVisit.getForwardVisits() + 1);
+        }
+        return exposedVisit;
+    }
+
+    protected ExposedVisitEntity newExposedVisit(Visit visit, int slotIndex) {
+        // TODO: visit.getPeriodStart returning an Instant
+        long periodStart = this.periodStartInstant(visit).toEpochMilli();
+        return ExposedVisitEntity.builder()
+                .locationTemporaryPublicId(visit.getLocationTemporaryPublicId())
+                .venueType(visit.getVenueType())
+                .venueCategory1(visit.getVenueCategory1())
+                .venueCategory2(visit.getVenueCategory2())
+                .periodStart(periodStart)
+                .timeSlot(slotIndex)
+                .backwardVisits(visit.isBackward() ? 1 : 0)
+                .forwardVisits(visit.isBackward() ? 0 : 1)
+                .qrCodeScanTime(visit.getQrCodeScanTime())
+                .build();
+    }
+
+    /**
+     * @return The exposure time of a visit expressed as the number of EXPOSURE_TIME_UNIT.
+     * e.g. if EXPOSURE_TIME_UNIT is 3600 sec (one hour), an exposure time equals to 3 means 3 hours
+     * if EXPOSURE_TIME_UNIT is 1800 sec (30 minutes), an exposure time equals to 3 means 1,5 hour.
+     */
+    protected int getExposureTime(int venueType, int venueCategory1, int venueCategory2, boolean staff) {
+        return 3;
+    }
+}
diff --git a/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/utils/KafkaDeserializer.java b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/utils/KafkaDeserializer.java
new file mode 100644
index 0000000000000000000000000000000000000000..d06900411f6758f016058ba8887ea607f82a75b7
--- /dev/null
+++ b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/utils/KafkaDeserializer.java
@@ -0,0 +1,67 @@
+package fr.gouv.clea.consumer.utils;
+
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.databind.DeserializationContext;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.deser.std.StdDeserializer;
+import com.fasterxml.jackson.databind.module.SimpleModule;
+import fr.gouv.clea.consumer.model.DecodedVisit;
+import fr.inria.clea.lsp.EncryptedLocationSpecificPart;
+import org.apache.kafka.common.errors.SerializationException;
+import org.apache.kafka.common.serialization.Deserializer;
+
+import java.io.IOException;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.UUID;
+
+public class KafkaDeserializer implements Deserializer<DecodedVisit> {
+
+    @Override
+    public DecodedVisit deserialize(String topic, byte[] data) {
+        if (data == null)
+            return null;
+        try {
+            return new ObjectMapper()
+                    .registerModule(new SimpleModule().addDeserializer(DecodedVisit.class, new CustomJacksonDeserializer()))
+                    .readValue(data, DecodedVisit.class);
+        } catch (IOException e) {
+            throw new SerializationException("Error deserializing JSON message", e);
+        }
+    }
+}
+
+class CustomJacksonDeserializer extends StdDeserializer<DecodedVisit> {
+
+    private static final long serialVersionUID = 1L;
+
+    public CustomJacksonDeserializer() {
+        this(null);
+    }
+
+    public CustomJacksonDeserializer(Class<DecodedVisit> t) {
+        super(t);
+    }
+
+    @Override
+    public DecodedVisit deserialize(JsonParser parser, DeserializationContext context) throws IOException {
+        JsonNode node = parser.getCodec().readTree(parser);
+        long qrCodeScanTime = node.get("qrCodeScanTime").asLong();
+        boolean isBackward = node.get("isBackward").asBoolean();
+        int version = node.get("version").asInt();
+        int type = node.get("type").asInt();
+        UUID locationTemporaryPublicId = UUID.fromString(node.get("locationTemporaryPublicId").asText());
+        byte[] encryptedLocationMessage = node.get("encryptedLocationMessage").binaryValue();
+        return new DecodedVisit(
+                Instant.ofEpochMilli(qrCodeScanTime).truncatedTo(ChronoUnit.SECONDS),
+                EncryptedLocationSpecificPart.builder()
+                        .version(version)
+                        .type(type)
+                        .locationTemporaryPublicId(locationTemporaryPublicId)
+                        .encryptedLocationMessage(encryptedLocationMessage)
+                        .build(),
+                isBackward
+        );
+    }
+}
diff --git a/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/utils/KafkaSerializer.java b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/utils/KafkaSerializer.java
new file mode 100644
index 0000000000000000000000000000000000000000..15f9396c8d39612f54ccb94b91dd235c95697901
--- /dev/null
+++ b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/utils/KafkaSerializer.java
@@ -0,0 +1,58 @@
+package fr.gouv.clea.consumer.utils;
+
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.SerializerProvider;
+import com.fasterxml.jackson.databind.module.SimpleModule;
+import com.fasterxml.jackson.databind.ser.std.StdSerializer;
+import fr.gouv.clea.consumer.model.DecodedVisit;
+import fr.inria.clea.lsp.EncryptedLocationSpecificPart;
+import org.apache.kafka.common.errors.SerializationException;
+import org.apache.kafka.common.serialization.Serializer;
+
+import java.io.IOException;
+
+public class KafkaSerializer implements Serializer<DecodedVisit> {
+
+    @Override
+    public byte[] serialize(String topic, DecodedVisit data) {
+        if (data == null)
+            return null;
+        try {
+            return new ObjectMapper()
+                    .registerModule(new SimpleModule().addSerializer(DecodedVisit.class, new CustomJacksonSerializer()))
+                    .writeValueAsBytes(data);
+        } catch (JsonProcessingException e) {
+            throw new SerializationException("Error serializing JSON message", e);
+        }
+    }
+}
+
+class CustomJacksonSerializer extends StdSerializer<DecodedVisit> {
+
+    private static final long serialVersionUID = 1L;
+
+    public CustomJacksonSerializer() {
+        this(null);
+    }
+
+    public CustomJacksonSerializer(Class<DecodedVisit> t) {
+        super(t);
+    }
+
+    @Override
+    public void serialize(DecodedVisit visit, JsonGenerator generator, SerializerProvider provider) throws IOException {
+        long qrCodeScanTime = visit.getQrCodeScanTime().toEpochMilli();
+        boolean isBackward = visit.isBackward();
+        EncryptedLocationSpecificPart enc = visit.getEncryptedLocationSpecificPart();
+        generator.writeStartObject();
+        generator.writeNumberField("qrCodeScanTime", qrCodeScanTime);
+        generator.writeBooleanField("isBackward", isBackward);
+        generator.writeNumberField("version", enc.getVersion());
+        generator.writeNumberField("type", enc.getType());
+        generator.writeStringField("locationTemporaryPublicId", enc.getLocationTemporaryPublicId().toString());
+        generator.writeBinaryField("encryptedLocationMessage", enc.getEncryptedLocationMessage());
+        generator.writeEndObject();
+    }
+}
diff --git a/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/utils/MessageFormatter.java b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/utils/MessageFormatter.java
new file mode 100644
index 0000000000000000000000000000000000000000..8a7c543343ade996f5b2e22e93cf1cfcf8f8fcd2
--- /dev/null
+++ b/clea/clea-venue-consumer/src/main/java/fr/gouv/clea/consumer/utils/MessageFormatter.java
@@ -0,0 +1,8 @@
+package fr.gouv.clea.consumer.utils;
+
+public class MessageFormatter {
+
+    public static String truncateUUID(String message) {
+        return message.substring(0, Math.min(message.length(), 10)).concat("...");
+    }
+}
diff --git a/clea/clea-venue-consumer/src/main/resources/application-docker.yml b/clea/clea-venue-consumer/src/main/resources/application-docker.yml
new file mode 100644
index 0000000000000000000000000000000000000000..0a3a7f0edd7eb5047e3b2d35414ee0ffb7725e18
--- /dev/null
+++ b/clea/clea-venue-consumer/src/main/resources/application-docker.yml
@@ -0,0 +1,23 @@
+server:
+  port: 8080
+
+spring:
+  datasource:
+    driverClassName: org.postgresql.Driver
+    url: "${DB_URL:jdbc:postgresql://postgres:5432/cleadb}"
+    username: "${DB_USER:user}"
+    password: "${DB_PASSWORD:pass}"
+  h2:
+    console:
+      enabled: false
+  jpa:
+    hibernate:
+      ddl-auto: ${JPA_DDL_AUTO:create}
+  kafka:
+    bootstrap-servers: "${KAFKA_URL:kafka:29092}"
+
+clea:
+  conf:
+    security:
+      crypto:
+        manualCTAuthoritySecretKey: ${CLEA_CRYPTO_AUTHORITY_SECRET:f55d39487b91c00ad24d4fc70f54511539ed308e954ab859734598d16d047bdf}
diff --git a/clea/clea-venue-consumer/src/main/resources/application-local.yml b/clea/clea-venue-consumer/src/main/resources/application-local.yml
new file mode 100644
index 0000000000000000000000000000000000000000..23f9a96bac405d3968b2434559c680d0dcc94907
--- /dev/null
+++ b/clea/clea-venue-consumer/src/main/resources/application-local.yml
@@ -0,0 +1,16 @@
+spring:
+  datasource:
+    driverClassName: org.h2.Driver
+    password:
+    url: "jdbc:h2:mem:cleadb"
+    username: sa
+  h2:
+    console:
+      enabled: true
+      path: /h2
+      settings:
+        trace: false
+        web-allow-others: true
+  jpa:
+    hibernate:
+      ddl-auto: create-drop
diff --git a/clea/clea-venue-consumer/src/main/resources/application.yml b/clea/clea-venue-consumer/src/main/resources/application.yml
new file mode 100644
index 0000000000000000000000000000000000000000..dc7dd76f2d1e366180ae9b9e4d33d04b4e135c84
--- /dev/null
+++ b/clea/clea-venue-consumer/src/main/resources/application.yml
@@ -0,0 +1,45 @@
+# Available endpoints for the monitoring
+management:
+  endpoints:
+    web:
+      exposure:
+        include: ${CLEA_SERVER_MONITORING_ENDPOINTS:health,metrics}
+
+clea:
+  conf:
+    retentionDurationInDays: 14
+    driftBetweenDeviceAndOfficialTimeInSecs: 300
+    cleaClockDriftInSecs: 300
+    scheduling:
+      purge:
+        cron: "0 0 1 * * *"
+        enabled: false
+    security:
+      crypto:
+        manualCTAuthoritySecretKey: "${CLEA_CRYPTO_AUTHORITY_SECRET:f55d39487b91c00ad24d4fc70f54511539ed308e954ab859734598d16d047bdf}"
+
+logging:
+  level:
+    fr.gouv.clea.consumer: INFO
+
+spring:
+  jpa:
+    generate-ddl: true
+    open-in-view: false
+    database-platform: org.hibernate.dialect.PostgreSQL95Dialect # use postgres because of @Type(pg-uuid) even with h2 Datasource
+    hibernate:
+      ddl-auto: ${JPA_DDL_AUTO:create}
+  kafka:
+    bootstrap-servers: "${KAFKA_URL:localhost:9092}"
+    consumer:
+      auto-offset-reset: earliest
+      group-id: ${KAFKA_GROUP_ID:group1}
+      key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
+      value-deserializer: fr.gouv.clea.consumer.utils.KafkaDeserializer
+    template:
+      default-topic: ${KAFKA_TOPIC:cleaQrCodes}
+  main:
+    web-application-type: none
+  output:
+    ansi:
+      enabled: ALWAYS
diff --git a/clea/clea-venue-consumer/src/main/resources/banner.txt b/clea/clea-venue-consumer/src/main/resources/banner.txt
new file mode 100644
index 0000000000000000000000000000000000000000..46db76f673fd4fe4cad0316cd5349733807cd6ec
--- /dev/null
+++ b/clea/clea-venue-consumer/src/main/resources/banner.txt
@@ -0,0 +1,8 @@
+   ___ _               ___                                
+  / __| |___ __ _ ___ / __|___ _ _  ____  _ _ __  ___ _ _ 
+ | (__| / -_) _` |___| (__/ _ \ ' \(_-< || | '  \/ -_) '_|
+  \___|_\___\__,_|    \___\___/_||_/__/\_,_|_|_|_\___|_|  
+
+${application.title}:${application.version}
+    Powered-by   : Spring-Boot ${spring-boot.formatted-version}
+    Run with     : Java ${java.version} ${java.vm.version}
diff --git a/clea/clea-venue-consumer/src/main/resources/bootstrap.yml b/clea/clea-venue-consumer/src/main/resources/bootstrap.yml
new file mode 100644
index 0000000000000000000000000000000000000000..17a99ea2cce65ad9f1b2075191c957d8f7185205
--- /dev/null
+++ b/clea/clea-venue-consumer/src/main/resources/bootstrap.yml
@@ -0,0 +1,17 @@
+spring:
+  application:
+    name: clea-venue-consumer
+  cloud:
+    consul:
+      enabled: ${CONSUL_ENABLED:false}
+      host: ${CONSUL_HOST:localhost}
+      port: ${CONSUL_PORT:8500}
+      scheme: ${CONSUL_SCHEME:http}
+      config:
+        enabled: ${CONSUL_CONFIG_ENABLED:false}
+    vault:
+      enabled: ${VAULT_ENABLED:false}
+      host: ${VAULT_HOST:localhost}
+      port: ${VAULT_PORT:8200}
+      token: ${VAULT_TOKEN:token}
+      scheme: ${VAULT_SCHEME:http}
diff --git a/clea/clea-venue-consumer/src/test/java/fr/gouv/clea/consumer/service/impl/ConsumerServiceTest.java b/clea/clea-venue-consumer/src/test/java/fr/gouv/clea/consumer/service/impl/ConsumerServiceTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..ab7c1e9b0c66d1c5a2240667c2690c4c86d9fc7e
--- /dev/null
+++ b/clea/clea-venue-consumer/src/test/java/fr/gouv/clea/consumer/service/impl/ConsumerServiceTest.java
@@ -0,0 +1,88 @@
+package fr.gouv.clea.consumer.service.impl;
+
+import fr.gouv.clea.consumer.model.DecodedVisit;
+import fr.gouv.clea.consumer.service.IConsumerService;
+import fr.gouv.clea.consumer.utils.KafkaSerializer;
+import fr.inria.clea.lsp.EncryptedLocationSpecificPart;
+import org.apache.commons.lang3.RandomUtils;
+import org.apache.kafka.clients.producer.Producer;
+import org.apache.kafka.clients.producer.ProducerRecord;
+import org.apache.kafka.common.serialization.StringSerializer;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.mock.mockito.SpyBean;
+import org.springframework.kafka.core.DefaultKafkaProducerFactory;
+import org.springframework.kafka.test.EmbeddedKafkaBroker;
+import org.springframework.kafka.test.context.EmbeddedKafka;
+import org.springframework.kafka.test.utils.KafkaTestUtils;
+import org.springframework.test.annotation.DirtiesContext;
+import org.springframework.test.context.TestPropertySource;
+
+import java.time.Instant;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+
+import static org.awaitility.Awaitility.await;
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+@SpringBootTest
+@DirtiesContext
+@EmbeddedKafka(partitions = 1, brokerProperties = {"listeners=PLAINTEXT://localhost:9092", "port=9092"})
+@TestPropertySource(properties = {"kafka.bootstrapAddress=localhost:9092"})
+class ConsumerServiceTest {
+
+    @Value("${spring.kafka.template.default-topic}")
+    private String topicName;
+
+    @Autowired
+    private EmbeddedKafkaBroker embeddedKafkaBroker;
+
+    @SpyBean
+    private IConsumerService consumerService;
+
+    private Producer<String, DecodedVisit> producer;
+
+    @BeforeEach
+    void init() {
+        Map<String, Object> configs = new HashMap<>(KafkaTestUtils.producerProps(embeddedKafkaBroker));
+        producer = new DefaultKafkaProducerFactory<>(configs, new StringSerializer(), new KafkaSerializer()).createProducer();
+    }
+
+    @AfterEach
+    void clean() {
+        producer.close();
+    }
+
+    @Test
+    @DisplayName("test that kafka listener triggers when something is sent to the queue")
+    void testCanConsumeMessageSentinDefaultQueue() {
+        DecodedVisit decodedVisit = new DecodedVisit(
+                Instant.now(),
+                EncryptedLocationSpecificPart.builder()
+                        .version(RandomUtils.nextInt())
+                        .type(RandomUtils.nextInt())
+                        .locationTemporaryPublicId(UUID.randomUUID())
+                        .encryptedLocationMessage(RandomUtils.nextBytes(20))
+                        .build(),
+                RandomUtils.nextBoolean()
+        );
+
+        producer.send(new ProducerRecord<>(topicName, decodedVisit));
+        producer.flush();
+
+        await().atMost(30, TimeUnit.SECONDS)
+                .untilAsserted(
+                        () -> verify(consumerService, times(1))
+                                .consume(any(DecodedVisit.class))
+                );
+    }
+}
\ No newline at end of file
diff --git a/clea/clea-venue-consumer/src/test/java/fr/gouv/clea/consumer/service/impl/DecodedVisitServiceTest.java b/clea/clea-venue-consumer/src/test/java/fr/gouv/clea/consumer/service/impl/DecodedVisitServiceTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..d35cae5f4552066bdc164f48057eb0fd039197e7
--- /dev/null
+++ b/clea/clea-venue-consumer/src/test/java/fr/gouv/clea/consumer/service/impl/DecodedVisitServiceTest.java
@@ -0,0 +1,219 @@
+package fr.gouv.clea.consumer.service.impl;
+
+import fr.gouv.clea.consumer.model.DecodedVisit;
+import fr.gouv.clea.consumer.model.Visit;
+import fr.gouv.clea.consumer.service.IDecodedVisitService;
+import fr.inria.clea.lsp.CleaEciesEncoder;
+import fr.inria.clea.lsp.CleaEncodingException;
+import fr.inria.clea.lsp.CleaEncryptionException;
+import fr.inria.clea.lsp.EncryptedLocationSpecificPart;
+import fr.inria.clea.lsp.LocationSpecificPart;
+import fr.inria.clea.lsp.LocationSpecificPartDecoder;
+import fr.inria.clea.lsp.utils.TimeUtils;
+import org.apache.commons.lang3.RandomUtils;
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.time.Instant;
+import java.util.Optional;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class DecodedVisitServiceTest {
+
+    private final LocationSpecificPartDecoder decoder = mock(LocationSpecificPartDecoder.class);
+    private final CleaEciesEncoder cleaEciesEncoder = mock(CleaEciesEncoder.class);
+    private final int driftBetweenDeviceAndOfficialTimeInSecs = 300;
+    private final int cleaClockDriftInSecs = 300;
+    private final IDecodedVisitService decodedVisitService = new DecodedVisitService(decoder, cleaEciesEncoder, driftBetweenDeviceAndOfficialTimeInSecs, cleaClockDriftInSecs);
+
+    @Test
+    @DisplayName("check with max CRIexp for LSP")
+    void maxCRIexp() throws CleaEncryptionException, CleaEncodingException {
+        int CRIexp = 0x1F; // qrCodeRenewalInterval = 0
+        Instant now = Instant.now();
+        UUID uuid = UUID.randomUUID();
+        byte[] locationTemporarySecretKey = RandomUtils.nextBytes(20);
+
+        when(decoder.decrypt(any(EncryptedLocationSpecificPart.class)))
+                .thenReturn(
+                        LocationSpecificPart.builder()
+                                .qrCodeRenewalIntervalExponentCompact(CRIexp)
+                                .locationTemporaryPublicId(uuid)
+                                .locationTemporarySecretKey(locationTemporarySecretKey)
+                                .build()
+                );
+
+        when(cleaEciesEncoder.computeLocationTemporaryPublicId(locationTemporarySecretKey))
+                .thenReturn(uuid);
+
+        Optional<Visit> optional = decodedVisitService.decryptAndValidate(
+                new DecodedVisit(
+                        now,
+                        EncryptedLocationSpecificPart.builder()
+                                .locationTemporaryPublicId(uuid)
+                                .build(),
+                        RandomUtils.nextBoolean()
+                )
+        );
+
+        assertThat(optional).isPresent();
+    }
+
+    @Disabled
+    @Test
+    @DisplayName("check with drifting LSP")
+    void drift() throws CleaEncryptionException, CleaEncodingException {
+        int CRIexp = 10; // qrCodeRenewalInterval=2^10(=1024). 1024+300+300=1624
+        Instant now = Instant.now();
+        long qrCodeValidityStartTime = TimeUtils.currentNtpTime() + 2000;
+        UUID uuid = UUID.randomUUID();
+        byte[] locationTemporarySecretKey = RandomUtils.nextBytes(20);
+
+        when(decoder.decrypt(any(EncryptedLocationSpecificPart.class)))
+                .thenReturn(
+                        LocationSpecificPart.builder()
+                                .qrCodeValidityStartTime(0)
+                                .qrCodeRenewalIntervalExponentCompact(CRIexp)
+                                // .qrCodeValidityStartTime(qrCodeValidityStartTime)
+                                .locationTemporaryPublicId(uuid)
+                                .locationTemporarySecretKey(locationTemporarySecretKey)
+                                .build()
+                );
+
+        when(cleaEciesEncoder.computeLocationTemporaryPublicId(locationTemporarySecretKey))
+                .thenReturn(uuid);
+
+        Optional<Visit> optional = decodedVisitService.decryptAndValidate(
+                new DecodedVisit(
+                        now,
+                        EncryptedLocationSpecificPart.builder()
+                                .locationTemporaryPublicId(uuid)
+                                .build(),
+                        RandomUtils.nextBoolean()
+                )
+        );
+
+        assertThat(optional).isEmpty();
+    }
+
+    @Test
+    @DisplayName("check with non valid temporaryLocationPublicId")
+    void nonValidTemporaryLocationPublicId() throws CleaEncryptionException, CleaEncodingException {
+        Instant now = Instant.now();
+        UUID uuid = UUID.randomUUID();
+        UUID _uuid = UUID.randomUUID();
+        byte[] locationTemporarySecretKey = RandomUtils.nextBytes(20);
+
+        when(decoder.decrypt(any(EncryptedLocationSpecificPart.class)))
+                .thenReturn(
+                        LocationSpecificPart.builder()
+                                .locationTemporaryPublicId(uuid)
+                                .locationTemporarySecretKey(locationTemporarySecretKey)
+                                .build()
+                );
+
+        when(cleaEciesEncoder.computeLocationTemporaryPublicId(locationTemporarySecretKey))
+                .thenReturn(_uuid);
+
+        Optional<Visit> optional = decodedVisitService.decryptAndValidate(
+                new DecodedVisit(
+                        now,
+                        EncryptedLocationSpecificPart.builder()
+                                .locationTemporaryPublicId(uuid)
+                                .build(),
+                        RandomUtils.nextBoolean()
+                )
+        );
+
+        assertThat(optional).isNotPresent();
+    }
+
+    @Test
+    @DisplayName("check with CleaEncryptionException when verifying temporaryLocationPublicId")
+    void cleaEncryptionExceptionForTLId() throws CleaEncryptionException, CleaEncodingException {
+        Instant now = Instant.now();
+        UUID uuid = UUID.randomUUID();
+        byte[] locationTemporarySecretKey = RandomUtils.nextBytes(20);
+
+        when(decoder.decrypt(any(EncryptedLocationSpecificPart.class)))
+                .thenReturn(
+                        LocationSpecificPart.builder()
+                                .locationTemporaryPublicId(uuid)
+                                .locationTemporarySecretKey(locationTemporarySecretKey)
+                                .build()
+                );
+
+        when(cleaEciesEncoder.computeLocationTemporaryPublicId(locationTemporarySecretKey))
+                .thenThrow(new CleaEncryptionException(""));
+
+        Optional<Visit> optional = decodedVisitService.decryptAndValidate(
+                new DecodedVisit(
+                        now,
+                        EncryptedLocationSpecificPart.builder()
+                                .locationTemporaryPublicId(uuid)
+                                .build(),
+                        RandomUtils.nextBoolean()
+                )
+        );
+
+        assertThat(optional).isNotPresent();
+    }
+
+    @Test
+    @DisplayName("check with CleaEncryptionException when decrypting")
+    void cleaEncryptionException() throws CleaEncryptionException, CleaEncodingException {
+        Instant now = Instant.now();
+        UUID uuid = UUID.randomUUID();
+        byte[] locationTemporarySecretKey = RandomUtils.nextBytes(20);
+
+        when(decoder.decrypt(any(EncryptedLocationSpecificPart.class)))
+                .thenThrow(new CleaEncryptionException(""));
+
+        when(cleaEciesEncoder.computeLocationTemporaryPublicId(locationTemporarySecretKey))
+                .thenReturn(uuid);
+
+        Optional<Visit> optional = decodedVisitService.decryptAndValidate(
+                new DecodedVisit(
+                        now,
+                        EncryptedLocationSpecificPart.builder()
+                                .locationTemporaryPublicId(uuid)
+                                .build(),
+                        RandomUtils.nextBoolean()
+                )
+        );
+
+        assertThat(optional).isNotPresent();
+    }
+
+    @Test
+    @DisplayName("check with CleaEncodingException when decrypting")
+    void cleaEncodingException() throws CleaEncryptionException, CleaEncodingException {
+        Instant now = Instant.now();
+        UUID uuid = UUID.randomUUID();
+        byte[] locationTemporarySecretKey = RandomUtils.nextBytes(20);
+
+        when(decoder.decrypt(any(EncryptedLocationSpecificPart.class)))
+                .thenThrow(new CleaEncodingException(""));
+
+        when(cleaEciesEncoder.computeLocationTemporaryPublicId(locationTemporarySecretKey))
+                .thenReturn(uuid);
+
+        Optional<Visit> optional = decodedVisitService.decryptAndValidate(
+                new DecodedVisit(
+                        now,
+                        EncryptedLocationSpecificPart.builder()
+                                .locationTemporaryPublicId(uuid)
+                                .build(),
+                        RandomUtils.nextBoolean()
+                )
+        );
+
+        assertThat(optional).isNotPresent();
+    }
+}
\ No newline at end of file
diff --git a/clea/clea-venue-consumer/src/test/java/fr/gouv/clea/consumer/service/impl/ExposedVisitEntityServiceSchedulingTest.java b/clea/clea-venue-consumer/src/test/java/fr/gouv/clea/consumer/service/impl/ExposedVisitEntityServiceSchedulingTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..9f49345f6af55274f039205a50399693fb60951d
--- /dev/null
+++ b/clea/clea-venue-consumer/src/test/java/fr/gouv/clea/consumer/service/impl/ExposedVisitEntityServiceSchedulingTest.java
@@ -0,0 +1,102 @@
+package fr.gouv.clea.consumer.service.impl;
+
+import fr.gouv.clea.consumer.model.ExposedVisitEntity;
+import fr.gouv.clea.consumer.repository.IExposedVisitRepository;
+import org.apache.commons.lang3.RandomUtils;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.scheduling.config.CronTask;
+import org.springframework.scheduling.config.ScheduledTask;
+import org.springframework.scheduling.config.ScheduledTaskHolder;
+import org.springframework.test.annotation.DirtiesContext;
+import org.springframework.test.context.TestPropertySource;
+
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.List;
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.awaitility.Awaitility.await;
+
+@SpringBootTest
+@DirtiesContext
+@TestPropertySource(properties = {"clea.conf.scheduling.purge.cron=*/10 * * * * *", "clea.conf.scheduling.purge.enabled=true"})
+class ExposedVisitEntityServiceSchedulingTest {
+
+    @Value("${clea.conf.scheduling.purge.cron}")
+    private String cronValue;
+
+    @Autowired
+    private IExposedVisitRepository repository;
+
+    @Autowired
+    private ScheduledTaskHolder scheduledTaskHolder;
+
+    private static ExposedVisitEntity createExposedVisit(Instant qrCodeScanTime) {
+        return new ExposedVisitEntity(
+                null, // handled by db
+                UUID.randomUUID(),
+                RandomUtils.nextInt(),
+                RandomUtils.nextInt(),
+                RandomUtils.nextInt(),
+                RandomUtils.nextLong(),
+                RandomUtils.nextInt(),
+                RandomUtils.nextLong(),
+                RandomUtils.nextLong(),
+                qrCodeScanTime,
+                null, // handled by db
+                null // handled by db
+        );
+    }
+
+    @AfterEach
+    void clean() {
+        repository.deleteAll();
+    }
+
+    @Test
+    @DisplayName("check that croned job is active")
+    void testCronIsActive() {
+        Set<ScheduledTask> scheduledTasks = scheduledTaskHolder.getScheduledTasks();
+        scheduledTasks.forEach(scheduledTask -> scheduledTask.getTask().getRunnable().getClass().getDeclaredMethods());
+        long count = scheduledTasks.stream()
+                .filter(scheduledTask -> scheduledTask.getTask() instanceof CronTask)
+                .map(scheduledTask -> (CronTask) scheduledTask.getTask())
+                .filter(cronTask -> cronTask.getExpression().equals(cronValue)
+                        && cronTask.toString().equals("fr.gouv.clea.consumer.service.impl.ExposedVisitEntityService.deleteOutdatedExposedVisits"))
+                .count();
+
+        assertThat(count).isEqualTo(1L);
+    }
+
+    @Test
+    @DisplayName("check that croned job remove outdated exposed visits from DB")
+    void deleteOutdatedExposedVisits() {
+        Instant now = Instant.now();
+        Instant yesterday = now.minus(1, ChronoUnit.DAYS);
+        Instant _14DaysAgo = now.minus(14, ChronoUnit.DAYS);
+        Instant _15DaysAgo = now.minus(15, ChronoUnit.DAYS);
+        repository.saveAll(
+                List.of(
+                        createExposedVisit(yesterday), // keep
+                        createExposedVisit(_14DaysAgo), // remove, will always be < now
+                        createExposedVisit(_15DaysAgo) // remove
+                )
+        );
+        assertThat(repository.count()).isEqualTo(3);
+
+        await().atMost(30, TimeUnit.SECONDS)
+                .untilAsserted(() -> assertThat(repository.count()).isEqualTo(1));
+
+        assertThat(repository.findAll().stream().anyMatch(it -> it.getQrCodeScanTime().equals(yesterday))).isTrue();
+        assertThat(repository.findAll().stream().anyMatch(it -> it.getQrCodeScanTime().equals(_14DaysAgo))).isFalse();
+        assertThat(repository.findAll().stream().anyMatch(it -> it.getQrCodeScanTime().equals(_15DaysAgo))).isFalse();
+    }
+}
\ No newline at end of file
diff --git a/clea/clea-venue-consumer/src/test/java/fr/gouv/clea/consumer/service/impl/VisitExpositionAggregatorServiceTest.java b/clea/clea-venue-consumer/src/test/java/fr/gouv/clea/consumer/service/impl/VisitExpositionAggregatorServiceTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..0981d4adb174d5036a1b3241ac2d81329f8c8caf
--- /dev/null
+++ b/clea/clea-venue-consumer/src/test/java/fr/gouv/clea/consumer/service/impl/VisitExpositionAggregatorServiceTest.java
@@ -0,0 +1,176 @@
+package fr.gouv.clea.consumer.service.impl;
+
+import fr.gouv.clea.consumer.model.ExposedVisitEntity;
+import fr.gouv.clea.consumer.model.Visit;
+import fr.gouv.clea.consumer.repository.IExposedVisitRepository;
+import fr.gouv.clea.consumer.service.IVisitExpositionAggregatorService;
+import org.apache.commons.lang3.RandomUtils;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.annotation.DirtiesContext;
+
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.List;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.fail;
+
+@SpringBootTest
+@DirtiesContext
+class VisitExpositionAggregatorServiceTest {
+
+    @Autowired
+    private IExposedVisitRepository repository;
+
+    @Autowired
+    private IVisitExpositionAggregatorService service;
+
+    private Instant yesterday;
+    private UUID uuid;
+    private byte[] locationTemporarySecretKey;
+    private byte[] encryptedLocationContactMessage;
+
+    @BeforeEach
+    void init() {
+        yesterday = Instant.now().minus(1, ChronoUnit.DAYS);
+        uuid = UUID.randomUUID();
+        locationTemporarySecretKey = RandomUtils.nextBytes(20);
+        encryptedLocationContactMessage = RandomUtils.nextBytes(20);
+    }
+
+    @AfterEach
+    void clean() {
+        repository.deleteAll();
+    }
+
+    @Test
+    @DisplayName("visits with no existing context should be saved in DB")
+    void saveWithNoContext() {
+        Visit visit = Visit.builder()
+                .version(0)
+                .type(0)
+                .countryCode(33)
+                .staff(true)
+                .locationTemporaryPublicId(uuid)
+                .qrCodeRenewalIntervalExponentCompact(2)
+                .venueType(4)
+                .venueCategory1(0)
+                .venueCategory2(0)
+                .periodDuration(3)
+                .compressedPeriodStartTime(1062707)
+                .qrCodeValidityStartTime(0)
+                .locationTemporarySecretKey(locationTemporarySecretKey)
+                .encryptedLocationContactMessage(encryptedLocationContactMessage)
+                .qrCodeScanTime(yesterday)
+                .isBackward(true)
+                .build();
+        service.updateExposureCount(visit);
+
+        // assertThat(repository.count()).isEqualTo(21L);
+        List<ExposedVisitEntity> entities = repository.findAll();
+        entities.forEach(it -> {
+                    assertThat(it.getLocationTemporaryPublicId()).isEqualTo(uuid);
+                    assertThat(it.getBackwardVisits()).isEqualTo(1);
+                }
+        );
+    }
+
+    @Test
+    @DisplayName("visits with existing context should be updated in DB")
+    void updateWithExistingContext() {
+        Visit visit = Visit.builder()
+                .version(0)
+                .type(0)
+                .countryCode(33)
+                .staff(true)
+                .locationTemporaryPublicId(uuid)
+                .qrCodeRenewalIntervalExponentCompact(2)
+                .venueType(4)
+                .venueCategory1(0)
+                .venueCategory2(0)
+                .periodDuration(3)
+                .compressedPeriodStartTime(1062707)
+                .qrCodeValidityStartTime(0)
+                .locationTemporarySecretKey(locationTemporarySecretKey)
+                .encryptedLocationContactMessage(encryptedLocationContactMessage)
+                .qrCodeScanTime(yesterday)
+                .isBackward(true)
+                .build();
+        service.updateExposureCount(visit);
+        service.updateExposureCount(visit);
+
+        // assertThat(repository.count()).isEqualTo(21L);
+        List<ExposedVisitEntity> entities = repository.findAll();
+        entities.forEach(it -> {
+                    assertThat(it.getLocationTemporaryPublicId()).isEqualTo(uuid);
+                    assertThat(it.getBackwardVisits()).isEqualTo(2);
+                }
+        );
+    }
+
+    @Test
+    @DisplayName("new visits should be saved while existing be updated in DB")
+    void mixedContext() {
+        Visit visit = Visit.builder()
+                .version(0)
+                .type(0)
+                .countryCode(33)
+                .staff(true)
+                .locationTemporaryPublicId(uuid)
+                .qrCodeRenewalIntervalExponentCompact(2)
+                .venueType(4)
+                .venueCategory1(0)
+                .venueCategory2(0)
+                .periodDuration(3)
+                .compressedPeriodStartTime(1062707)
+                .qrCodeValidityStartTime(0)
+                .locationTemporarySecretKey(locationTemporarySecretKey)
+                .encryptedLocationContactMessage(encryptedLocationContactMessage)
+                .qrCodeScanTime(yesterday)
+                .isBackward(true)
+                .build();
+        service.updateExposureCount(visit);
+
+        visit.setBackward(false);
+        service.updateExposureCount(visit);
+
+        UUID newUUID = UUID.randomUUID();
+        visit.setLocationTemporaryPublicId(newUUID);
+        visit.setBackward(true);
+        service.updateExposureCount(visit);
+
+        // assertThat(repository.count()).isEqualTo(42L);
+        List<ExposedVisitEntity> entities = repository.findAll();
+        entities.stream()
+                .filter(it -> it.getLocationTemporaryPublicId().equals(uuid))
+                .forEach(it -> {
+                            assertThat(it.getLocationTemporaryPublicId()).isEqualTo(uuid);
+                            assertThat(it.getBackwardVisits()).isEqualTo(1);
+                            assertThat(it.getForwardVisits()).isEqualTo(1);
+                        }
+                );
+
+        entities.stream()
+                .filter(it -> it.getLocationTemporaryPublicId().equals(newUUID))
+                .forEach(it -> {
+                            assertThat(it.getLocationTemporaryPublicId()).isEqualTo(newUUID);
+                            assertThat(it.getBackwardVisits()).isEqualTo(1);
+                            assertThat(it.getForwardVisits()).isEqualTo(0);
+                        }
+                );
+    }
+
+    @Disabled
+    @Test
+    @DisplayName("test how many slots are generated for a given visit")
+    void testSlotGeneration() {
+        fail("Not tested yet");
+    }
+}
\ No newline at end of file
diff --git a/clea/clea-ws-rest/Dockerfile b/clea/clea-ws-rest/Dockerfile
new file mode 100644
index 0000000000000000000000000000000000000000..836a07d803f06bbdadd753a4eac50484495d16ff
--- /dev/null
+++ b/clea/clea-ws-rest/Dockerfile
@@ -0,0 +1,10 @@
+FROM azul/zulu-openjdk-alpine:11
+
+RUN adduser -D javaapp
+
+COPY ./target/*-exec.jar /home/javaapp/clea-ws-rest.jar
+
+WORKDIR /home/javaapp
+
+ENTRYPOINT ["java","-jar","clea-ws-rest.jar"]
+EXPOSE 8080
diff --git a/clea/clea-ws-rest/README.md b/clea/clea-ws-rest/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..298124310f38aaa24da24d557c8b86451143699f
--- /dev/null
+++ b/clea/clea-ws-rest/README.md
@@ -0,0 +1,9 @@
+Run the Clea WS ReST application:
+
+```bash
+mvn spring-boot:run
+```
+
+Display the Clea API and use it: http://localhost:8088/swagger-ui/
+
+Display the Clea OpenAPI generated from the code: http://localhost:8088/v3/api-docs?group=clea
\ No newline at end of file
diff --git a/clea/clea-ws-rest/pom.xml b/clea/clea-ws-rest/pom.xml
new file mode 100644
index 0000000000000000000000000000000000000000..32894f0edc68c62449c72887f4747f741750ab02
--- /dev/null
+++ b/clea/clea-ws-rest/pom.xml
@@ -0,0 +1,142 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+
+    <modelVersion>4.0.0</modelVersion>
+
+    <parent>
+        <groupId>fr.gouv.clea</groupId>
+        <artifactId>clea-server</artifactId>
+        <version>0.1-SNAPSHOT</version>
+    </parent>
+
+    <artifactId>clea-ws-rest</artifactId>
+    <name>clea-ws-rest</name>
+    <description>ReST API module for Clea (Cluster Exposure Verification)</description>
+
+    <properties>
+        <springfox-boot-starter.version>3.0.0</springfox-boot-starter.version>
+        <io.jsonwebtoken.version>0.11.2</io.jsonwebtoken.version>
+    </properties>
+
+    <dependencies>
+        <dependency>
+            <groupId>io.springfox</groupId>
+            <artifactId>springfox-boot-starter</artifactId>
+            <version>${springfox-boot-starter.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>io.jsonwebtoken</groupId>
+            <artifactId>jjwt-api</artifactId>
+            <version>${io.jsonwebtoken.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>io.jsonwebtoken</groupId>
+            <artifactId>jjwt-impl</artifactId>
+            <version>${io.jsonwebtoken.version}</version>
+            <scope>runtime</scope>
+        </dependency>
+        <dependency>
+            <groupId>io.jsonwebtoken</groupId>
+            <artifactId>jjwt-jackson</artifactId>
+            <version>${io.jsonwebtoken.version}</version>
+            <scope>runtime</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.kafka</groupId>
+            <artifactId>spring-kafka</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.kafka</groupId>
+            <artifactId>spring-kafka-test</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.testcontainers</groupId>
+            <artifactId>kafka</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>fr.inria.clea</groupId>
+            <artifactId>clea-crypto</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-web</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-actuator</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.projectlombok</groupId>
+            <artifactId>lombok</artifactId>
+            <optional>true</optional>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-test</artifactId>
+            <scope>test</scope>
+            <exclusions>
+                <exclusion>
+                    <groupId>org.junit.vintage</groupId>
+                    <artifactId>junit-vintage-engine</artifactId>
+                </exclusion>
+            </exclusions>
+        </dependency>
+        <dependency>
+            <groupId>io.micrometer</groupId>
+            <artifactId>micrometer-core</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>io.micrometer</groupId>
+            <artifactId>micrometer-registry-prometheus</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.cloud</groupId>
+            <artifactId>spring-cloud-starter-bootstrap</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.cloud</groupId>
+            <artifactId>spring-cloud-vault-config-consul</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.cloud</groupId>
+            <artifactId>spring-cloud-starter-consul-config</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-configuration-processor</artifactId>
+            <optional>true</optional>
+        </dependency>
+        <dependency>
+            <groupId>org.mockito</groupId>
+            <artifactId>mockito-core</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.assertj</groupId>
+            <artifactId>assertj-core</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.awaitility</groupId>
+            <artifactId>awaitility</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.commons</groupId>
+            <artifactId>commons-lang3</artifactId>
+        </dependency>
+    </dependencies>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.springframework.boot</groupId>
+                <artifactId>spring-boot-maven-plugin</artifactId>
+            </plugin>
+        </plugins>
+    </build>
+
+</project>
diff --git a/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/CleaWsRestApplication.java b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/CleaWsRestApplication.java
new file mode 100644
index 0000000000000000000000000000000000000000..dfe92d4dd35506c4075297b27758a6eb32b4b601
--- /dev/null
+++ b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/CleaWsRestApplication.java
@@ -0,0 +1,12 @@
+package fr.gouv.clea.ws;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication(scanBasePackages = "fr.gouv.clea.ws")
+public class CleaWsRestApplication {
+
+    public static void main(String[] args) {
+        SpringApplication.run(CleaWsRestApplication.class, args);
+    }
+}
diff --git a/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/api/CleaWsRestAPI.java b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/api/CleaWsRestAPI.java
new file mode 100644
index 0000000000000000000000000000000000000000..3438c32cf9b67ac26578f4a6d9934373ed8f5748
--- /dev/null
+++ b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/api/CleaWsRestAPI.java
@@ -0,0 +1,54 @@
+package fr.gouv.clea.ws.api;
+
+import fr.gouv.clea.ws.dto.ReportResponse;
+import fr.gouv.clea.ws.vo.ReportRequest;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import io.swagger.annotations.ApiResponse;
+import io.swagger.annotations.ApiResponses;
+import io.swagger.annotations.Example;
+import io.swagger.annotations.ExampleProperty;
+import org.springframework.http.MediaType;
+
+@Api(
+        tags = "clea",
+        description = "Clea API",
+        consumes = MediaType.APPLICATION_JSON_VALUE,
+        produces = MediaType.APPLICATION_JSON_VALUE
+)
+public interface CleaWsRestAPI {
+
+    @ApiOperation(
+            value = "Upload locations history",
+            notes = "" +
+                    "Upload a list of {qrCode, timestamp} tuples where :\n" +
+                    "* **qrCode**: QR code content encoded in Base64\n" +
+                    "* **qrCodeScanTime**: NTP timestamp when a user terminal scans a given QR code\n" +
+                    "",
+            httpMethod = "POST",
+            response = ReportResponse.class,
+            protocols = "https"
+    )
+    @ApiResponses(
+            value = {
+                    @ApiResponse(
+                            code = 200,
+                            message = "Successful Operation",
+                            response = ReportResponse.class,
+                            examples = @Example(
+                                    @ExampleProperty(
+                                            value = "{\n" +
+                                                    "  \"success\": \"true\",\n" +
+                                                    "  \"message\": \"2 qr processed, 0 rejected\"\n" +
+                                                    "}",
+                                            mediaType = MediaType.APPLICATION_JSON_VALUE
+                                    )
+                            )
+                    ),
+                    @ApiResponse(code = 400, message = "Bad Request"),
+                    @ApiResponse(code = 401, message = "Invalid Authentication"),
+                    @ApiResponse(code = 500, message = "Internal Error")
+            }
+    )
+    ReportResponse report(ReportRequest reportRequestVo);
+}
diff --git a/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/configuration/SecurityConfiguration.java b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/configuration/SecurityConfiguration.java
new file mode 100644
index 0000000000000000000000000000000000000000..0c6cbcd706371739c5465c2c0cfef08bbc923a5e
--- /dev/null
+++ b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/configuration/SecurityConfiguration.java
@@ -0,0 +1,14 @@
+package fr.gouv.clea.ws.configuration;
+
+import fr.inria.clea.lsp.LocationSpecificPartDecoder;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+public class SecurityConfiguration {
+
+    @Bean
+    public LocationSpecificPartDecoder getLocationSpecificPartDecoder() {
+        return new LocationSpecificPartDecoder(null);
+    }
+}
diff --git a/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/configuration/SwaggerConfiguration.java b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/configuration/SwaggerConfiguration.java
new file mode 100644
index 0000000000000000000000000000000000000000..c7a8fa67f80c753184d1ed2197155aa6f8ad1758
--- /dev/null
+++ b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/configuration/SwaggerConfiguration.java
@@ -0,0 +1,65 @@
+package fr.gouv.clea.ws.configuration;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.http.HttpMethod;
+import springfox.documentation.builders.PathSelectors;
+import springfox.documentation.service.ApiInfo;
+import springfox.documentation.service.ApiKey;
+import springfox.documentation.service.AuthorizationScope;
+import springfox.documentation.service.Contact;
+import springfox.documentation.service.SecurityReference;
+import springfox.documentation.spi.DocumentationType;
+import springfox.documentation.spi.service.contexts.SecurityContext;
+import springfox.documentation.spring.web.plugins.Docket;
+import springfox.documentation.swagger2.annotations.EnableSwagger2;
+
+import java.util.List;
+
+import static springfox.documentation.builders.RequestHandlerSelectors.basePackage;
+
+@Configuration
+@EnableSwagger2
+public class SwaggerConfiguration {
+
+    private ApiKey apiKey() {
+        return new ApiKey("Authorization", "Authorization", "header");
+    }
+
+    private SecurityContext securityContext() {
+        return SecurityContext.builder().securityReferences(defaultAuth()).build();
+    }
+
+    private List<SecurityReference> defaultAuth() {
+        AuthorizationScope authorizationScope = new AuthorizationScope("global", "accessEverything");
+        AuthorizationScope[] authorizationScopes = new AuthorizationScope[1];
+        authorizationScopes[0] = authorizationScope;
+        return List.of(new SecurityReference("Authorization", authorizationScopes));
+    }
+
+    @Bean
+    public Docket cleaApi() {
+        return new Docket(DocumentationType.OAS_30)
+                .select()
+                .apis(basePackage("fr.gouv.clea.ws"))
+                .paths(PathSelectors.regex("/api/.*"))
+                .build()
+                .apiInfo(
+                        new ApiInfo(
+                                "Tous AntiCovid Cluster Exposure Verification (Cléa)",
+                                "#TOUSANTICOVID, Cléa API",
+                                "1.0.0",
+                                null,
+                                new Contact(null, null, "stopcovid@inria.fr"),
+                                "Apache 2.0",
+                                "http://www.apache.org/licenses/LICENSE-2.0.html",
+                                List.of()
+                        )
+                )
+                .groupName("clea")
+                .securityContexts(List.of(securityContext()))
+                .securitySchemes(List.of(apiKey()))
+                .globalResponses(HttpMethod.GET, List.of())
+                .globalResponses(HttpMethod.POST, List.of());
+    }
+}
diff --git a/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/controller/CleaController.java b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/controller/CleaController.java
new file mode 100644
index 0000000000000000000000000000000000000000..88902fa70ed68cd75519a4c6608b1b254da73522
--- /dev/null
+++ b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/controller/CleaController.java
@@ -0,0 +1,121 @@
+package fr.gouv.clea.ws.controller;
+
+import com.fasterxml.jackson.databind.DeserializationContext;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.deser.DeserializationProblemHandler;
+import fr.gouv.clea.ws.api.CleaWsRestAPI;
+import fr.gouv.clea.ws.dto.ReportResponse;
+import fr.gouv.clea.ws.service.IAuthorizationService;
+import fr.gouv.clea.ws.service.IReportService;
+import fr.gouv.clea.ws.utils.BadArgumentsLoggerService;
+import fr.gouv.clea.ws.utils.UriConstants;
+import fr.gouv.clea.ws.vo.ReportRequest;
+import fr.gouv.clea.ws.vo.Visit;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.context.request.WebRequest;
+import org.springframework.web.server.ResponseStatusException;
+
+import javax.annotation.PostConstruct;
+import javax.validation.ConstraintViolation;
+import javax.validation.Valid;
+import javax.validation.Validator;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+@RestController
+@RequestMapping(path = "${controller.path.prefix}")
+@Slf4j
+public class CleaController implements CleaWsRestAPI {
+
+    public static final String MALFORMED_VISIT_LOG_MESSAGE = "Filtered out %d malformed visits of %d while Exposure Status Request";
+    private final IReportService reportService;
+    private final IAuthorizationService authorizationService;
+    private final BadArgumentsLoggerService badArgumentsLoggerService;
+    private final WebRequest webRequest;
+    private final ObjectMapper objectMapper;
+    private final Validator validator;
+
+    @Autowired
+    public CleaController(
+            IReportService reportService,
+            IAuthorizationService authorizationService,
+            BadArgumentsLoggerService badArgumentsLoggerService,
+            WebRequest webRequest,
+            ObjectMapper objectMapper,
+            Validator validator
+    ) {
+        this.reportService = reportService;
+        this.authorizationService = authorizationService;
+        this.badArgumentsLoggerService = badArgumentsLoggerService;
+        this.webRequest = webRequest;
+        this.objectMapper = objectMapper;
+        this.validator = validator;
+    }
+
+    @Override
+    @PostMapping(
+            path = UriConstants.API_V1 + UriConstants.REPORT,
+            consumes = MediaType.APPLICATION_JSON_VALUE,
+            produces = MediaType.APPLICATION_JSON_VALUE
+    )
+    // TODO: Also we should switch from AuthorizationService to SpringSecurity using jwtDecoder
+    public ReportResponse report(@RequestBody @Valid ReportRequest reportRequestVo) {
+        String auth = webRequest.getHeader("Authorization");
+        this.authorizationService.checkAuthorization(auth);
+        ReportRequest filtered = this.filterReports(reportRequestVo, webRequest);
+        reportService.report(filtered);
+        String message = String.format("%s reports processed, %s rejected", filtered.getVisits().size(), reportRequestVo.getVisits().size() - filtered.getVisits().size());
+        log.info(message);
+        return new ReportResponse(true, message);
+    }
+
+    protected ReportRequest filterReports(ReportRequest report, WebRequest webRequest) {
+        Set<ConstraintViolation<ReportRequest>> superViolations = validator.validate(report);
+        if (!superViolations.isEmpty()) {
+            this.badArgumentsLoggerService.logValidationErrorMessage(superViolations, webRequest);
+            log.warn(String.format(MALFORMED_VISIT_LOG_MESSAGE, report.getVisits().size(), report.getVisits().size()));
+            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Failed to validate request");
+        } else {
+            List<Visit> validVisits = report.getVisits().stream()
+                    .filter(visit -> {
+                        Set<ConstraintViolation<Visit>> subViolations = validator.validate(visit);
+                        if (!subViolations.isEmpty()) {
+                            this.badArgumentsLoggerService.logValidationErrorMessage(subViolations, webRequest);
+                            return false;
+                        } else {
+                            return true;
+                        }
+                    }).collect(Collectors.toList());
+            int nbVisits = report.getVisits().size();
+            int nbFilteredVisits = nbVisits - validVisits.size();
+            if (nbFilteredVisits > 0) {
+                log.warn(String.format(MALFORMED_VISIT_LOG_MESSAGE, nbFilteredVisits, nbVisits));
+            }
+            return new ReportRequest(validVisits, report.getPivotDateAsNtpTimestamp());
+        }
+    }
+
+    @PostConstruct
+    private void disableAutomaticJsonDeserialization() {
+        objectMapper.addHandler(new DeserializationProblemHandler() {
+            @Override
+            public Object handleWeirdStringValue(DeserializationContext ctxt, Class<?> targetType, String valueToConvert, String failureMsg) {
+                return null;
+            }
+
+            @Override
+            public Object handleWeirdNumberValue(DeserializationContext ctxt, Class<?> targetType, Number valueToConvert, String failureMsg) {
+                return null;
+            }
+        });
+    }
+
+}
diff --git a/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/dto/ReportResponse.java b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/dto/ReportResponse.java
new file mode 100644
index 0000000000000000000000000000000000000000..8f2235ee12483a9f0df4796902456c57c9d081d6
--- /dev/null
+++ b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/dto/ReportResponse.java
@@ -0,0 +1,13 @@
+package fr.gouv.clea.ws.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@AllArgsConstructor
+@NoArgsConstructor
+public class ReportResponse {
+    private boolean success;
+    private String message;
+}
diff --git a/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/exception/CleaUnauthorizedException.java b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/exception/CleaUnauthorizedException.java
new file mode 100644
index 0000000000000000000000000000000000000000..ee372ad973880e72a6933c2e362f9a4ac25d68fc
--- /dev/null
+++ b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/exception/CleaUnauthorizedException.java
@@ -0,0 +1,15 @@
+package fr.gouv.clea.ws.exception;
+
+import lombok.NoArgsConstructor;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.ResponseStatus;
+
+@NoArgsConstructor
+@ResponseStatus(HttpStatus.FORBIDDEN)
+public class CleaUnauthorizedException extends RuntimeException {
+    private static final long serialVersionUID = -2742936878446030656L;
+
+    public CleaUnauthorizedException(String message) {
+        super(message);
+    }
+}
diff --git a/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/exception/CustomRestExceptionHandler.java b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/exception/CustomRestExceptionHandler.java
new file mode 100644
index 0000000000000000000000000000000000000000..55966375de61652de24c391cc1c78af542ee630d
--- /dev/null
+++ b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/exception/CustomRestExceptionHandler.java
@@ -0,0 +1,76 @@
+package fr.gouv.clea.ws.exception;
+
+import fr.gouv.clea.ws.utils.BadArgumentsLoggerService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.http.converter.HttpMessageNotReadableException;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.annotation.ControllerAdvice;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.ResponseStatus;
+import org.springframework.web.context.request.WebRequest;
+import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
+
+@ControllerAdvice
+@Slf4j
+public class CustomRestExceptionHandler extends ResponseEntityExceptionHandler {
+
+    public static final String ERROR_MESSAGE_TEMPLATE = "%s, requested uri: %s";
+
+    private final BadArgumentsLoggerService badArgumentsLoggerService;
+
+    public CustomRestExceptionHandler(BadArgumentsLoggerService badArgumentsLoggerService) {
+        this.badArgumentsLoggerService = badArgumentsLoggerService;
+    }
+
+    /**
+     * A general handler for all uncaught exceptions
+     *
+     * @throws Exception
+     */
+    @ExceptionHandler({Exception.class})
+    public ResponseEntity<Object> handleAllExceptions(Exception exception, WebRequest webRequest) {
+        String message = exception.getLocalizedMessage();
+        if (message == null)
+            message = exception.toString();
+        final String path = webRequest.getDescription(false);
+        ResponseStatus responseStatus = exception.getClass().getAnnotation(ResponseStatus.class);
+        final HttpStatus status = responseStatus != null ? responseStatus.value() : HttpStatus.INTERNAL_SERVER_ERROR;
+        log.error(String.format(ERROR_MESSAGE_TEMPLATE, message, path), exception);
+        return this.newResponseEntity(null, status);
+    }
+
+    /**
+     * Handle BAD REQUEST exceptions
+     */
+    @Override
+    protected ResponseEntity<Object> handleMethodArgumentNotValid(
+            MethodArgumentNotValidException exception,
+            HttpHeaders headers,
+            HttpStatus status,
+            WebRequest webRequest) {
+        this.badArgumentsLoggerService.logValidationErrorMessage(exception.getBindingResult(), webRequest);
+        return this.newResponseEntity(null, status);
+    }
+
+    @Override
+    protected ResponseEntity<Object> handleHttpMessageNotReadable(
+            HttpMessageNotReadableException ex,
+            HttpHeaders headers,
+            HttpStatus status,
+            WebRequest request) {
+        log.warn("Bad Request: ", ex.getMessage());
+        log.debug("Bad Request: ", ex);
+
+        return this.newResponseEntity(null, HttpStatus.BAD_REQUEST);
+    }
+
+    protected ResponseEntity<Object> newResponseEntity(Object body, HttpStatus status) {
+        HttpHeaders headers = new HttpHeaders();
+        headers.setContentType(MediaType.APPLICATION_JSON);
+        return new ResponseEntity<>(null, headers, status);
+    }
+}
diff --git a/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/model/DecodedVisit.java b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/model/DecodedVisit.java
new file mode 100644
index 0000000000000000000000000000000000000000..8e6f8650364515bc44e93ec93c031be26782744f
--- /dev/null
+++ b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/model/DecodedVisit.java
@@ -0,0 +1,26 @@
+package fr.gouv.clea.ws.model;
+
+import fr.inria.clea.lsp.EncryptedLocationSpecificPart;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.ToString;
+
+import java.time.Instant;
+import java.util.UUID;
+
+@AllArgsConstructor
+@Getter
+@ToString
+public class DecodedVisit {
+    private final Instant qrCodeScanTime; // t_qrScan
+    private final EncryptedLocationSpecificPart encryptedLocationSpecificPart;
+    private final boolean isBackward;
+
+    public UUID getLocationTemporaryPublicId() {
+        return this.encryptedLocationSpecificPart.getLocationTemporaryPublicId();
+    }
+
+    public String getStringLocationTemporaryPublicId() {
+        return this.getLocationTemporaryPublicId().toString();
+    }
+}
diff --git a/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/service/IAuthorizationService.java b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/service/IAuthorizationService.java
new file mode 100644
index 0000000000000000000000000000000000000000..44684c9556bd13f504c1de5dc47df5dd72b81afd
--- /dev/null
+++ b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/service/IAuthorizationService.java
@@ -0,0 +1,8 @@
+package fr.gouv.clea.ws.service;
+
+import fr.gouv.clea.ws.exception.CleaUnauthorizedException;
+
+public interface IAuthorizationService {
+
+    boolean checkAuthorization(String jwtToken) throws CleaUnauthorizedException;
+}
diff --git a/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/service/IDecodedVisitProducerService.java b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/service/IDecodedVisitProducerService.java
new file mode 100644
index 0000000000000000000000000000000000000000..f68a91295a04602ef820268617f249008673fa8c
--- /dev/null
+++ b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/service/IDecodedVisitProducerService.java
@@ -0,0 +1,10 @@
+package fr.gouv.clea.ws.service;
+
+import fr.gouv.clea.ws.model.DecodedVisit;
+
+import java.util.List;
+
+public interface IDecodedVisitProducerService {
+
+    void produce(List<DecodedVisit> decodedVisits);
+}
diff --git a/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/service/IReportService.java b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/service/IReportService.java
new file mode 100644
index 0000000000000000000000000000000000000000..21b7e67b8e74355bf8e0b334aecb9b71f639477e
--- /dev/null
+++ b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/service/IReportService.java
@@ -0,0 +1,10 @@
+package fr.gouv.clea.ws.service;
+
+import fr.gouv.clea.ws.model.DecodedVisit;
+import fr.gouv.clea.ws.vo.ReportRequest;
+
+import java.util.List;
+
+public interface IReportService {
+    List<DecodedVisit> report(ReportRequest reportRequestVo);
+}
diff --git a/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/service/impl/AuthorizationService.java b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/service/impl/AuthorizationService.java
new file mode 100644
index 0000000000000000000000000000000000000000..af7a4e77044c8ef12d54b4e625636601a190c653
--- /dev/null
+++ b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/service/impl/AuthorizationService.java
@@ -0,0 +1,58 @@
+package fr.gouv.clea.ws.service.impl;
+
+import fr.gouv.clea.ws.exception.CleaUnauthorizedException;
+import fr.gouv.clea.ws.service.IAuthorizationService;
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.SignatureAlgorithm;
+import io.jsonwebtoken.io.Decoders;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+import java.security.KeyFactory;
+import java.security.PublicKey;
+import java.security.spec.X509EncodedKeySpec;
+
+@Service
+@Slf4j
+public class AuthorizationService implements IAuthorizationService {
+
+    private final boolean checkAuthorization;
+
+    private final String robertJwtPublicKey;
+
+    @Autowired
+    public AuthorizationService(
+            @Value("${clea.conf.security.report.checkAuthorization}") boolean checkAuthorization,
+            @Value("${clea.conf.security.report.robertJwtPublicKey}") String robertJwtPublicKey) {
+        this.checkAuthorization = checkAuthorization;
+        this.robertJwtPublicKey = robertJwtPublicKey;
+    }
+
+    public boolean checkAuthorization(String jwtToken) throws CleaUnauthorizedException {
+        if (this.checkAuthorization) {
+            if (jwtToken == null) {
+                log.warn("Missing Authorisation header!");
+                throw new CleaUnauthorizedException("Missing Authorisation header!");
+            }
+            jwtToken = jwtToken.replace("Bearer ", "");
+            this.verifyJWT(jwtToken);
+        }
+        return true;
+    }
+
+    private void verifyJWT(String token) throws CleaUnauthorizedException {
+        PublicKey jwtPublicKey;
+        try {
+            byte[] encoded = Decoders.BASE64.decode(this.robertJwtPublicKey);
+            KeyFactory keyFactory = KeyFactory.getInstance(SignatureAlgorithm.RS256.getFamilyName());
+            X509EncodedKeySpec keySpec = new X509EncodedKeySpec(encoded);
+            jwtPublicKey = keyFactory.generatePublic(keySpec);
+            Jwts.parserBuilder().setSigningKey(jwtPublicKey).build().parseClaimsJws(token);
+        } catch (Exception e) {
+            log.warn("Failed to verify JWT token!", e);
+            throw new CleaUnauthorizedException();
+        }
+    }
+}
diff --git a/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/service/impl/DecodedVisitProducerService.java b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/service/impl/DecodedVisitProducerService.java
new file mode 100644
index 0000000000000000000000000000000000000000..ff932106e39c7bbac96a9c2a682356af8d071f47
--- /dev/null
+++ b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/service/impl/DecodedVisitProducerService.java
@@ -0,0 +1,46 @@
+package fr.gouv.clea.ws.service.impl;
+
+import fr.gouv.clea.ws.model.DecodedVisit;
+import fr.gouv.clea.ws.service.IDecodedVisitProducerService;
+import fr.gouv.clea.ws.utils.MessageFormatter;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.kafka.core.KafkaTemplate;
+import org.springframework.kafka.support.SendResult;
+import org.springframework.stereotype.Service;
+import org.springframework.util.concurrent.ListenableFutureCallback;
+
+import java.util.List;
+
+@Service
+@Slf4j
+public class DecodedVisitProducerService implements IDecodedVisitProducerService {
+
+    private final KafkaTemplate<String, DecodedVisit> kafkaTemplate;
+
+    @Autowired
+    public DecodedVisitProducerService(
+            KafkaTemplate<String, DecodedVisit> kafkaTemplate) {
+        this.kafkaTemplate = kafkaTemplate;
+    }
+
+    @Override
+    public void produce(List<DecodedVisit> serializableDecodedVisits) {
+        serializableDecodedVisits.forEach(
+                it -> kafkaTemplate.sendDefault(it).addCallback(
+                        new ListenableFutureCallback<>() {
+                            @Override
+                            public void onFailure(Throwable ex) {
+                                // TODO: Do we want a mechanism to do not loose the message (e.g. spring retry)?
+                                log.error("error sending [locationTemporaryPublicId: {}, qrCodeScanTime: {}] to queue. message: {}", MessageFormatter.truncateUUID(it.getStringLocationTemporaryPublicId()), it.getQrCodeScanTime(), ex.getLocalizedMessage());
+                            }
+
+                            @Override
+                            public void onSuccess(SendResult<String, DecodedVisit> result) {
+                                log.info("[locationTemporaryPublicId: {}, qrCodeScanTime: {}] sent to queue", MessageFormatter.truncateUUID(it.getStringLocationTemporaryPublicId()), it.getQrCodeScanTime());
+                            }
+                        }
+                )
+        );
+    }
+}
diff --git a/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/service/impl/ReportService.java b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/service/impl/ReportService.java
new file mode 100644
index 0000000000000000000000000000000000000000..d137b0796107866e9bef2084782d42e82faff18d
--- /dev/null
+++ b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/service/impl/ReportService.java
@@ -0,0 +1,118 @@
+package fr.gouv.clea.ws.service.impl;
+
+import fr.gouv.clea.ws.model.DecodedVisit;
+import fr.gouv.clea.ws.service.IDecodedVisitProducerService;
+import fr.gouv.clea.ws.service.IReportService;
+import fr.gouv.clea.ws.utils.MessageFormatter;
+import fr.gouv.clea.ws.vo.ReportRequest;
+import fr.gouv.clea.ws.vo.Visit;
+import fr.inria.clea.lsp.CleaEncodingException;
+import fr.inria.clea.lsp.EncryptedLocationSpecificPart;
+import fr.inria.clea.lsp.LocationSpecificPartDecoder;
+import fr.inria.clea.lsp.utils.TimeUtils;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.List;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+@Service
+@Slf4j
+public class ReportService implements IReportService {
+
+    private final int retentionDurationInDays;
+
+    private final long duplicateScanThresholdInSeconds;
+
+    private final LocationSpecificPartDecoder decoder;
+
+    private final IDecodedVisitProducerService processService;
+
+    @Autowired
+    public ReportService(
+            @Value("${clea.conf.retentionDurationInDays}") int retentionDuration,
+            @Value("${clea.conf.duplicateScanThresholdInSeconds}") long duplicateScanThreshold,
+            LocationSpecificPartDecoder decoder,
+            IDecodedVisitProducerService processService) {
+        this.retentionDurationInDays = retentionDuration;
+        this.duplicateScanThresholdInSeconds = duplicateScanThreshold;
+        this.decoder = decoder;
+        this.processService = processService;
+    }
+
+    @Override
+    public List<DecodedVisit> report(ReportRequest reportRequestVo) {
+        List<DecodedVisit> verified = reportRequestVo.getVisits().stream()
+                .filter(visit -> !this.isOutdated(visit))
+                .filter(visit -> !this.isFuture(visit))
+                .map(it -> this.decode(it, reportRequestVo.getPivotDateAsNtpTimestamp()))
+                .filter(Objects::nonNull)
+                .collect(Collectors.toList());
+        List<DecodedVisit> pruned = this.pruneDuplicates(verified);
+        processService.produce(pruned);
+        return pruned;
+    }
+
+    private DecodedVisit decode(Visit visit, long pivotDate) {
+        try {
+            byte[] binaryLocationSpecificPart = Base64.getDecoder().decode(visit.getQrCode());
+            EncryptedLocationSpecificPart encryptedLocationSpecificPart = decoder.decodeHeader(binaryLocationSpecificPart);
+            Instant qrCodeScanTime = TimeUtils.instantFromTimestamp(visit.getQrCodeScanTimeAsNtpTimestamp());
+            return new DecodedVisit(qrCodeScanTime, encryptedLocationSpecificPart, visit.getQrCodeScanTimeAsNtpTimestamp() < pivotDate);
+        } catch (CleaEncodingException e) {
+            log.warn("report: {} rejected: Invalid format", MessageFormatter.truncateQrCode(visit.getQrCode()));
+            return null;
+        }
+    }
+
+    private boolean isOutdated(Visit visit) {
+        boolean outdated = ChronoUnit.DAYS.between(TimeUtils.instantFromTimestamp(visit.getQrCodeScanTimeAsNtpTimestamp()), Instant.now()) > retentionDurationInDays;
+        if (outdated) {
+            log.warn("report: {} rejected: Outdated", MessageFormatter.truncateQrCode(visit.getQrCode()));
+        }
+        return outdated;
+    }
+
+    private boolean isFuture(Visit visit) {
+        boolean future = TimeUtils.instantFromTimestamp(visit.getQrCodeScanTimeAsNtpTimestamp()).isAfter(Instant.now());
+        if (future) {
+            log.warn("report: {} rejected: In future", MessageFormatter.truncateQrCode(visit.getQrCode()));
+        }
+        return future;
+    }
+
+    private boolean isDuplicatedScan(DecodedVisit lsp, List<DecodedVisit> cleaned) {
+        return cleaned.stream().anyMatch(cleanedLsp -> this.isDuplicatedScan(lsp, cleanedLsp));
+    }
+
+    private boolean isDuplicatedScan(DecodedVisit one, DecodedVisit other) {
+        if (one.getLocationTemporaryPublicId() != other.getLocationTemporaryPublicId()) {
+            return false;
+        }
+
+        long secondsBetweenScans = Duration.between(one.getQrCodeScanTime(), other.getQrCodeScanTime()).abs().toSeconds();
+        if (secondsBetweenScans <= duplicateScanThresholdInSeconds) {
+            log.warn("report: {} {} rejected: Duplicate", MessageFormatter.truncateUUID(one.getStringLocationTemporaryPublicId()), one.getQrCodeScanTime());
+            return true;
+        }
+        return false;
+    }
+
+    private List<DecodedVisit> pruneDuplicates(List<DecodedVisit> locationSpecificParts) {
+        List<DecodedVisit> cleaned = new ArrayList<>();
+        locationSpecificParts.forEach(it -> {
+            if (!this.isDuplicatedScan(it, cleaned)) {
+                cleaned.add(it);
+            }
+        });
+        return cleaned;
+    }
+}
diff --git a/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/utils/BadArgumentsLoggerService.java b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/utils/BadArgumentsLoggerService.java
new file mode 100644
index 0000000000000000000000000000000000000000..de9873c884af302629f52b3b8a926932d2ecf884
--- /dev/null
+++ b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/utils/BadArgumentsLoggerService.java
@@ -0,0 +1,27 @@
+package fr.gouv.clea.ws.utils;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.validation.BindingResult;
+import org.springframework.web.context.request.WebRequest;
+
+import javax.validation.ConstraintViolation;
+import java.util.Set;
+
+@Slf4j
+@Service
+public class BadArgumentsLoggerService {
+    public static final String INVALID_INPUT_TEMPLATE = "Invalid input data: %s, requested uri: %s";
+
+    public <T> void logValidationErrorMessage(Set<ConstraintViolation<T>> violations, WebRequest webRequest) {
+        final String path = webRequest.getDescription(false);
+        String message = violations.toString();
+        log.error(String.format(INVALID_INPUT_TEMPLATE, message, path));
+    }
+
+    public void logValidationErrorMessage(BindingResult bindingResult, WebRequest webRequest) {
+        final String path = webRequest.getDescription(false);
+        String message = bindingResult.toString();
+        log.error(String.format(INVALID_INPUT_TEMPLATE, message, path));
+    }
+}
diff --git a/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/utils/KafkaDeserializer.java b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/utils/KafkaDeserializer.java
new file mode 100644
index 0000000000000000000000000000000000000000..35a9f8d6b0205dd5768ee2b614e1cbbdeb0959ca
--- /dev/null
+++ b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/utils/KafkaDeserializer.java
@@ -0,0 +1,67 @@
+package fr.gouv.clea.ws.utils;
+
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.databind.DeserializationContext;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.deser.std.StdDeserializer;
+import com.fasterxml.jackson.databind.module.SimpleModule;
+import fr.gouv.clea.ws.model.DecodedVisit;
+import fr.inria.clea.lsp.EncryptedLocationSpecificPart;
+import org.apache.kafka.common.errors.SerializationException;
+import org.apache.kafka.common.serialization.Deserializer;
+
+import java.io.IOException;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.UUID;
+
+public class KafkaDeserializer implements Deserializer<DecodedVisit> {
+
+    @Override
+    public DecodedVisit deserialize(String topic, byte[] data) {
+        if (data == null)
+            return null;
+        try {
+            return new ObjectMapper()
+                    .registerModule(new SimpleModule().addDeserializer(DecodedVisit.class, new CustomJacksonDeserializer()))
+                    .readValue(data, DecodedVisit.class);
+        } catch (IOException e) {
+            throw new SerializationException("Error deserializing JSON message", e);
+        }
+    }
+}
+
+class CustomJacksonDeserializer extends StdDeserializer<DecodedVisit> {
+
+    private static final long serialVersionUID = 1L;
+
+    public CustomJacksonDeserializer() {
+        this(null);
+    }
+
+    public CustomJacksonDeserializer(Class<DecodedVisit> t) {
+        super(t);
+    }
+
+    @Override
+    public DecodedVisit deserialize(JsonParser parser, DeserializationContext context) throws IOException {
+        JsonNode node = parser.getCodec().readTree(parser);
+        long qrCodeScanTime = node.get("qrCodeScanTime").asLong();
+        boolean isBackward = node.get("isBackward").asBoolean();
+        int version = node.get("version").asInt();
+        int type = node.get("type").asInt();
+        UUID locationTemporaryPublicId = UUID.fromString(node.get("locationTemporaryPublicId").asText());
+        byte[] encryptedLocationMessage = node.get("encryptedLocationMessage").binaryValue();
+        return new DecodedVisit(
+                Instant.ofEpochMilli(qrCodeScanTime).truncatedTo(ChronoUnit.SECONDS),
+                EncryptedLocationSpecificPart.builder()
+                        .version(version)
+                        .type(type)
+                        .locationTemporaryPublicId(locationTemporaryPublicId)
+                        .encryptedLocationMessage(encryptedLocationMessage)
+                        .build(),
+                isBackward
+        );
+    }
+}
diff --git a/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/utils/KafkaSerializer.java b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/utils/KafkaSerializer.java
new file mode 100644
index 0000000000000000000000000000000000000000..c3564e35adbd6fe8e5b7774552f3f84133c8c748
--- /dev/null
+++ b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/utils/KafkaSerializer.java
@@ -0,0 +1,58 @@
+package fr.gouv.clea.ws.utils;
+
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.SerializerProvider;
+import com.fasterxml.jackson.databind.module.SimpleModule;
+import com.fasterxml.jackson.databind.ser.std.StdSerializer;
+import fr.gouv.clea.ws.model.DecodedVisit;
+import fr.inria.clea.lsp.EncryptedLocationSpecificPart;
+import org.apache.kafka.common.errors.SerializationException;
+import org.apache.kafka.common.serialization.Serializer;
+
+import java.io.IOException;
+
+public class KafkaSerializer implements Serializer<DecodedVisit> {
+
+    @Override
+    public byte[] serialize(String topic, DecodedVisit data) {
+        if (data == null)
+            return null;
+        try {
+            return new ObjectMapper()
+                    .registerModule(new SimpleModule().addSerializer(DecodedVisit.class, new CustomJacksonSerializer()))
+                    .writeValueAsBytes(data);
+        } catch (JsonProcessingException e) {
+            throw new SerializationException("Error serializing JSON message", e);
+        }
+    }
+}
+
+class CustomJacksonSerializer extends StdSerializer<DecodedVisit> {
+
+    private static final long serialVersionUID = 1L;
+
+    public CustomJacksonSerializer() {
+        this(null);
+    }
+
+    public CustomJacksonSerializer(Class<DecodedVisit> t) {
+        super(t);
+    }
+
+    @Override
+    public void serialize(DecodedVisit visit, JsonGenerator generator, SerializerProvider provider) throws IOException {
+        long qrCodeScanTime = visit.getQrCodeScanTime().toEpochMilli();
+        boolean isBackward = visit.isBackward();
+        EncryptedLocationSpecificPart enc = visit.getEncryptedLocationSpecificPart();
+        generator.writeStartObject();
+        generator.writeNumberField("qrCodeScanTime", qrCodeScanTime);
+        generator.writeBooleanField("isBackward", isBackward);
+        generator.writeNumberField("version", enc.getVersion());
+        generator.writeNumberField("type", enc.getType());
+        generator.writeStringField("locationTemporaryPublicId", enc.getLocationTemporaryPublicId().toString());
+        generator.writeBinaryField("encryptedLocationMessage", enc.getEncryptedLocationMessage());
+        generator.writeEndObject();
+    }
+}
diff --git a/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/utils/MessageFormatter.java b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/utils/MessageFormatter.java
new file mode 100644
index 0000000000000000000000000000000000000000..94bfef5c4141e878048aa7c7f93f1de693d253ea
--- /dev/null
+++ b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/utils/MessageFormatter.java
@@ -0,0 +1,12 @@
+package fr.gouv.clea.ws.utils;
+
+public class MessageFormatter {
+
+    public static String truncateUUID(String message) {
+        return message.substring(0, Math.min(message.length(), 10)).concat("...");
+    }
+
+    public static String truncateQrCode(String qrCode) {
+        return qrCode.substring(0, Math.min(qrCode.length(), 25)).concat("...");
+    }
+}
diff --git a/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/utils/UriConstants.java b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/utils/UriConstants.java
new file mode 100644
index 0000000000000000000000000000000000000000..690a89489e3b38157db66c2e07b32e66e3cbec3a
--- /dev/null
+++ b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/utils/UriConstants.java
@@ -0,0 +1,8 @@
+package fr.gouv.clea.ws.utils;
+
+public class UriConstants {
+
+    public static final String REPORT = "/wreport";
+
+    public static final String API_V1 = "/v1";
+}
diff --git a/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/vo/ReportRequest.java b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/vo/ReportRequest.java
new file mode 100644
index 0000000000000000000000000000000000000000..5f66a53d17fe7a98d57853013f031492245f71ce
--- /dev/null
+++ b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/vo/ReportRequest.java
@@ -0,0 +1,25 @@
+package fr.gouv.clea.ws.vo;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import javax.validation.Valid;
+import javax.validation.constraints.NotEmpty;
+import javax.validation.constraints.NotNull;
+import java.util.List;
+
+@Data
+@AllArgsConstructor
+@NoArgsConstructor
+@Valid
+public class ReportRequest {
+    @NotNull
+    @NotEmpty
+    private List<Visit> visits;
+
+    @NotNull
+    @JsonProperty("pivotDate")
+    private Long pivotDateAsNtpTimestamp;
+}
diff --git a/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/vo/Visit.java b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/vo/Visit.java
new file mode 100644
index 0000000000000000000000000000000000000000..1d5dbcb2c7764539cf88080d795464c0cb7c9e9d
--- /dev/null
+++ b/clea/clea-ws-rest/src/main/java/fr/gouv/clea/ws/vo/Visit.java
@@ -0,0 +1,24 @@
+package fr.gouv.clea.ws.vo;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.NotEmpty;
+import javax.validation.constraints.NotNull;
+
+@Data
+@AllArgsConstructor
+@NoArgsConstructor
+public class Visit {
+    @NotNull
+    @NotEmpty
+    @NotBlank
+    private String qrCode;
+
+    @NotNull
+    @JsonProperty("qrCodeScanTime")
+    private Long qrCodeScanTimeAsNtpTimestamp; // t_qrScan
+}
diff --git a/clea/clea-ws-rest/src/main/resources/api-report-v1.yml b/clea/clea-ws-rest/src/main/resources/api-report-v1.yml
new file mode 100644
index 0000000000000000000000000000000000000000..460d7986a0f462ca16613a94bc4b2e53bb78a0fb
--- /dev/null
+++ b/clea/clea-ws-rest/src/main/resources/api-report-v1.yml
@@ -0,0 +1,95 @@
+openapi: 3.0.3
+info:
+  title: Tous AntiCovid Cluster Exposure Verification (Cléa)
+  description: "#TOUSANTICOVID, Cléa API"
+  contact:
+    email: stopcovid@inria.fr
+  license:
+    name: Apache 2.0
+    url: http://www.apache.org/licenses/LICENSE-2.0.html
+  version: 1.0.0
+servers:
+  - url: http://localhost:8088
+    description: Inferred Url
+tags:
+  - name: clea
+    description: Clea API
+paths:
+  "/api/clea/v1/wreport":
+    post:
+      tags:
+        - clea
+      summary: Upload locations history
+      description: |
+        Upload a list of {qrCode, timestamp} tuples where :
+        * **qrCode**: QR code content encoded in Base64
+        * **qrCodeScanTime**: NTP timestamp when a user terminal scans a given QR code
+      operationId: reportUsingPOST
+      requestBody:
+        content:
+          application/json:
+            schema:
+              "$ref": "#/components/schemas/ReportRequest"
+      responses:
+        '200':
+          description: Successful Operation
+          content:
+            application/json:
+              schema:
+                "$ref": "#/components/schemas/ReportResponse"
+              examples:
+                example-0:
+                  value: |-
+                    {
+                      "success": "true",
+                      "message": "2 qr processed, 0 rejected"
+                    }
+        '400':
+          description: Bad Request
+        '401':
+          description: Invalid Authentication
+        '500':
+          description: Internal Error
+      security:
+        - Authorization:
+            - global
+components:
+  schemas:
+    ReportRequest:
+      title: ReportRequest
+      required:
+        - visits
+      type: object
+      properties:
+        pivotDate:
+          type: integer
+          format: int64
+        visits:
+          type: array
+          items:
+            "$ref": "#/components/schemas/Visit"
+    ReportResponse:
+      title: ReportResponse
+      type: object
+      properties:
+        message:
+          type: string
+        success:
+          type: boolean
+    Visit:
+      title: Visit
+      required:
+        - qrCode
+        - qrCodeScanTime
+      type: object
+      properties:
+        qrCode:
+          type: string
+        qrCodeScanTime:
+          type: integer
+          format: int64
+  securitySchemes:
+    Authorization:
+      type: apiKey
+      name: Authorization
+      in: header
diff --git a/clea/clea-ws-rest/src/main/resources/application-docker.yml b/clea/clea-ws-rest/src/main/resources/application-docker.yml
new file mode 100644
index 0000000000000000000000000000000000000000..82ad33a1d542216dfe4928d525cb1886b7edc8a2
--- /dev/null
+++ b/clea/clea-ws-rest/src/main/resources/application-docker.yml
@@ -0,0 +1,9 @@
+server:
+  port: 8080
+
+clea:
+  conf:
+    security:
+      report:
+        checkAuthorization: false
+
diff --git a/clea/clea-ws-rest/src/main/resources/application-local.yml b/clea/clea-ws-rest/src/main/resources/application-local.yml
new file mode 100644
index 0000000000000000000000000000000000000000..01105b346ec264e8354b274916c8351a0237329d
--- /dev/null
+++ b/clea/clea-ws-rest/src/main/resources/application-local.yml
@@ -0,0 +1,5 @@
+clea:
+  conf:
+    security:
+      report:
+        checkAuthorization: false
\ No newline at end of file
diff --git a/clea/clea-ws-rest/src/main/resources/application.yml b/clea/clea-ws-rest/src/main/resources/application.yml
new file mode 100644
index 0000000000000000000000000000000000000000..aee6355acf1d1f82f9270b914bb767f9d9d6066b
--- /dev/null
+++ b/clea/clea-ws-rest/src/main/resources/application.yml
@@ -0,0 +1,37 @@
+controller:
+  path:
+    prefix: /api/clea
+server:
+  port: 8088
+
+# Available endpoints for the monitoring
+management:
+  endpoints:
+    web:
+      exposure:
+        include: ${CLEA_SERVER_MONITORING_ENDPOINTS:health,metrics}
+
+clea:
+  conf:
+    duplicateScanThresholdInSeconds: 10800
+    retentionDurationInDays: 14
+    security:
+      report:
+        checkAuthorization: true # secured by default
+        robertJwtPublicKey: ${CLEA_SECURITY_JWT_KEY:TODO}
+
+logging:
+  level:
+    fr.gouv.clea.ws: INFO
+
+spring:
+  kafka:
+    bootstrap-servers: "${KAFKA_URL:localhost:9092}"
+    consumer:
+      group-id: ${KAFKA_GROUPID:group1}
+    producer:
+      key-serializer: org.apache.kafka.common.serialization.StringSerializer
+      value-serializer: fr.gouv.clea.ws.utils.KafkaSerializer
+    template:
+      default-topic: ${KAFKA_TOPIC:cleaQrCodes}
+
diff --git a/clea/clea-ws-rest/src/main/resources/banner.txt b/clea/clea-ws-rest/src/main/resources/banner.txt
new file mode 100644
index 0000000000000000000000000000000000000000..8f714fefab79608e831b35a3360c80ef53abe17f
--- /dev/null
+++ b/clea/clea-ws-rest/src/main/resources/banner.txt
@@ -0,0 +1,8 @@
+   ___ _            __      __      ___        _   
+  / __| |___ __ _ __\ \    / /_____| _ \___ __| |_ 
+ | (__| / -_) _` |___\ \/\/ (_-<___|   / -_|_-<  _|
+  \___|_\___\__,_|    \_/\_//__/   |_|_\___/__/\__|
+
+${application.title}:${application.version}
+    Powered-by   : Spring-Boot ${spring-boot.formatted-version}
+    Run with     : Java ${java.version} ${java.vm.version}
diff --git a/clea/clea-ws-rest/src/main/resources/bootstrap.yml b/clea/clea-ws-rest/src/main/resources/bootstrap.yml
new file mode 100644
index 0000000000000000000000000000000000000000..10d63aa9e038b0331a0fecd80a693d02f2eabc63
--- /dev/null
+++ b/clea/clea-ws-rest/src/main/resources/bootstrap.yml
@@ -0,0 +1,18 @@
+spring:
+  application:
+    name: clea-server
+  cloud:
+    consul:
+      enabled: ${CONSUL_ENABLED:false}
+      host: ${CONSUL_HOST:localhost}
+      port: ${CONSUL_PORT:8500}
+      token: ${CLEA_SERVER_CONSUL_ACL_TOKEN:token}
+      scheme: ${CONSUL_SCHEME:http}
+      config:
+        enabled: ${CONSUL_CONFIG_ENABLED:false}
+    vault:
+      enabled: ${VAULT_ENABLED:false}
+      host: ${VAULT_HOST:localhost}
+      port: ${VAULT_PORT:8200}
+      token: ${VAULT_TOKEN:token}
+      scheme: ${VAULT_SCHEME:http}
diff --git a/clea/clea-ws-rest/src/test/java/fr/gouv/clea/ws/controller/CleaControllerAuthEnabledTest.java b/clea/clea-ws-rest/src/test/java/fr/gouv/clea/ws/controller/CleaControllerAuthEnabledTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..92887ce4ac9086d40468eb8e8712413577b477db
--- /dev/null
+++ b/clea/clea-ws-rest/src/test/java/fr/gouv/clea/ws/controller/CleaControllerAuthEnabledTest.java
@@ -0,0 +1,62 @@
+package fr.gouv.clea.ws.controller;
+
+import fr.gouv.clea.ws.utils.UriConstants;
+import fr.gouv.clea.ws.vo.ReportRequest;
+import fr.gouv.clea.ws.vo.Visit;
+import org.apache.commons.lang3.RandomStringUtils;
+import org.apache.commons.lang3.RandomUtils;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
+import org.springframework.boot.test.web.client.TestRestTemplate;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.test.context.TestPropertySource;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
+@TestPropertySource(properties = {"clea.conf.security.report.checkAuthorization=true"})
+public class CleaControllerAuthEnabledTest {
+
+    @Value("${controller.path.prefix}" + UriConstants.API_V1)
+    private String pathPrefix;
+
+    @Autowired
+    private TestRestTemplate restTemplate;
+
+    @Test
+    public void testWhenReportRequestWithMissingAuthenticationThenGetBadRequest() {
+        List<Visit> visits = List.of(
+                new Visit(RandomStringUtils.randomAlphanumeric(20), RandomUtils.nextLong()),
+                new Visit(RandomStringUtils.randomAlphanumeric(20), RandomUtils.nextLong()),
+                new Visit(RandomStringUtils.randomAlphanumeric(20), RandomUtils.nextLong())
+        );
+        HttpEntity<ReportRequest> request = new HttpEntity<>(new ReportRequest(visits, 0L), CleaControllerTest.newJsonHeader());
+        ResponseEntity<String> response = restTemplate.postForEntity(pathPrefix + UriConstants.REPORT, request, String.class);
+
+        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
+    }
+
+    @Test
+    public void testWhenReportRequestWithNonValidAuthenticationThenGetUnauthorized() {
+        List<Visit> visits = List.of(
+                new Visit(RandomStringUtils.randomAlphanumeric(20), RandomUtils.nextLong()),
+                new Visit(RandomStringUtils.randomAlphanumeric(20), RandomUtils.nextLong()),
+                new Visit(RandomStringUtils.randomAlphanumeric(20), RandomUtils.nextLong())
+        );
+        HttpHeaders headers = CleaControllerTest.newJsonHeader();
+        headers.setBearerAuth("invalid JWT token");
+        HttpEntity<ReportRequest> reportEntity = new HttpEntity<>(new ReportRequest(visits, 0L), headers);
+
+        ResponseEntity<String> response = restTemplate.postForEntity(pathPrefix + UriConstants.REPORT, reportEntity, String.class);
+
+        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
+    }
+}
diff --git a/clea/clea-ws-rest/src/test/java/fr/gouv/clea/ws/controller/CleaControllerTest.java b/clea/clea-ws-rest/src/test/java/fr/gouv/clea/ws/controller/CleaControllerTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..ca7ce33a8e3a2938faf1c4cbd1e9355f387f1020
--- /dev/null
+++ b/clea/clea-ws-rest/src/test/java/fr/gouv/clea/ws/controller/CleaControllerTest.java
@@ -0,0 +1,214 @@
+package fr.gouv.clea.ws.controller;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import fr.gouv.clea.ws.service.impl.ReportService;
+import fr.gouv.clea.ws.utils.UriConstants;
+import fr.gouv.clea.ws.vo.ReportRequest;
+import fr.gouv.clea.ws.vo.Visit;
+import org.apache.commons.lang3.RandomStringUtils;
+import org.apache.commons.lang3.RandomUtils;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.Mockito;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.boot.test.web.client.TestRestTemplate;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+
+@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
+public class CleaControllerTest {
+
+    @Captor
+    ArgumentCaptor<ReportRequest> reportRequestArgumentCaptor;
+    @Value("${controller.path.prefix}" + UriConstants.API_V1)
+    private String pathPrefix;
+    @Autowired
+    private TestRestTemplate restTemplate;
+    @Autowired
+    private ObjectMapper objectMapper;
+    @MockBean
+    private ReportService reportService;
+
+    public static HttpHeaders newJsonHeader() {
+        HttpHeaders headers = new HttpHeaders();
+        headers.setContentType(MediaType.APPLICATION_JSON);
+        return headers;
+    }
+
+    @Test
+    public void testInfectedUserCanReportHimselfAsInfected() {
+        List<Visit> visits = List.of(new Visit("qrCode", 0L));
+        HttpEntity<ReportRequest> request = new HttpEntity<>(new ReportRequest(visits, 0L), newJsonHeader());
+        ResponseEntity<String> response = restTemplate.postForEntity(pathPrefix + UriConstants.REPORT, request, String.class);
+
+        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+    }
+
+    @Test
+    public void testWhenReportRequestWithInvalidMediaTypeThenGetUnsupportedMediaType() {
+        ResponseEntity<String> response = restTemplate.postForEntity(pathPrefix + UriConstants.REPORT, "foo", String.class);
+
+        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNSUPPORTED_MEDIA_TYPE);
+        verifyNoMoreInteractions(reportService);
+    }
+
+    @Test
+    public void testWhenReportRequestWithNullVisitListThenGetBadRequest() {
+        HttpEntity<ReportRequest> request = new HttpEntity<>(new ReportRequest(null, 0L), newJsonHeader());
+        ResponseEntity<String> response = restTemplate.postForEntity(pathPrefix + UriConstants.REPORT, request, String.class);
+
+        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+        verifyNoMoreInteractions(reportService);
+    }
+
+    @Test
+    public void testWhenReportRequestWithInvalidJsonDataThenGetBadRequest() throws JSONException {
+        JSONObject jsonObject = new JSONObject();
+        jsonObject.put("id", 1);
+        ResponseEntity<String> response = restTemplate.postForEntity(
+                pathPrefix + UriConstants.REPORT,
+                new HttpEntity<>(jsonObject.toString(), newJsonHeader()),
+                String.class
+        );
+
+        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+        verifyNoMoreInteractions(reportService);
+    }
+
+    @Test
+    @DisplayName("when pivotDate is null, reject everything")
+    void nullPivotDate() {
+        List<Visit> visits = List.of(new Visit(RandomStringUtils.randomAlphanumeric(20), RandomUtils.nextLong()));
+        HttpEntity<ReportRequest> request = new HttpEntity<>(new ReportRequest(visits, null), newJsonHeader());
+        ResponseEntity<String> response = restTemplate.postForEntity(pathPrefix + UriConstants.REPORT, request, String.class);
+        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+        verifyNoMoreInteractions(reportService);
+    }
+
+    @Test
+    @DisplayName("when pivotDate is not numeric, reject everything")
+    void notNumericPivotDate() throws JsonProcessingException {
+        ReportRequest reportRequest = new ReportRequest(List.of(new Visit(RandomStringUtils.randomAlphanumeric(20), 1L)), 0L);
+        String json = objectMapper.writeValueAsString(reportRequest);
+        String badJson = json.replace("0", "a");
+        HttpEntity<String> request = new HttpEntity<>(badJson, newJsonHeader());
+        ResponseEntity<String> response = restTemplate.postForEntity(pathPrefix + UriConstants.REPORT, request, String.class);
+        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+        verifyNoMoreInteractions(reportService);
+    }
+
+    @Test
+    @DisplayName("when visit list is null, reject everything")
+    void nullVisitList() {
+        HttpEntity<ReportRequest> request = new HttpEntity<>(new ReportRequest(null, 0L), newJsonHeader());
+        ResponseEntity<String> response = restTemplate.postForEntity(pathPrefix + UriConstants.REPORT, request, String.class);
+        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+        verifyNoMoreInteractions(reportService);
+    }
+
+    @Test
+    @DisplayName("when visit list is empty, reject everything")
+    void emptyVisitList() {
+        HttpEntity<ReportRequest> request = new HttpEntity<>(new ReportRequest(List.of(), 0L), newJsonHeader());
+        ResponseEntity<String> response = restTemplate.postForEntity(pathPrefix + UriConstants.REPORT, request, String.class);
+        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+        verifyNoMoreInteractions(reportService);
+    }
+
+    @Test
+    @DisplayName("when a qrCode is null reject just the visit")
+    void nullQrCode() {
+        HttpEntity<ReportRequest> request = new HttpEntity<>(
+                new ReportRequest(List.of(new Visit("qr1", 1L), new Visit(null, 2L)), 3L),
+                newJsonHeader()
+        );
+        ResponseEntity<String> response = restTemplate.postForEntity(pathPrefix + UriConstants.REPORT, request, String.class);
+        Mockito.verify(reportService).report(reportRequestArgumentCaptor.capture());
+        assertThat(reportRequestArgumentCaptor.getValue().getPivotDateAsNtpTimestamp()).isEqualTo(3L);
+        assertThat(reportRequestArgumentCaptor.getValue().getVisits().size()).isEqualTo(1);
+        assertThat(reportRequestArgumentCaptor.getValue().getVisits().stream().filter(it -> it.getQrCode() == null).findAny()).isEmpty();
+        assertThat(reportRequestArgumentCaptor.getValue().getVisits().get(0).getQrCode()).isEqualTo("qr1");
+        assertThat(reportRequestArgumentCaptor.getValue().getVisits().get(0).getQrCodeScanTimeAsNtpTimestamp()).isEqualTo(1L);
+        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+    }
+
+    @Test
+    @DisplayName("when a qrCode is empty reject just the visit")
+    void emptyQrCode() {
+        HttpEntity<ReportRequest> request = new HttpEntity<>(
+                new ReportRequest(List.of(new Visit("qr1", 1L), new Visit("", 2L)), 3L),
+                newJsonHeader()
+        );
+        ResponseEntity<String> response = restTemplate.postForEntity(pathPrefix + UriConstants.REPORT, request, String.class);
+        Mockito.verify(reportService).report(reportRequestArgumentCaptor.capture());
+        assertThat(reportRequestArgumentCaptor.getValue().getPivotDateAsNtpTimestamp()).isEqualTo(3L);
+        assertThat(reportRequestArgumentCaptor.getValue().getVisits().size()).isEqualTo(1);
+        assertThat(reportRequestArgumentCaptor.getValue().getVisits().stream().filter(it -> it.getQrCode().isEmpty()).findAny()).isEmpty();
+        assertThat(reportRequestArgumentCaptor.getValue().getVisits().get(0).getQrCode()).isEqualTo("qr1");
+        assertThat(reportRequestArgumentCaptor.getValue().getVisits().get(0).getQrCodeScanTimeAsNtpTimestamp()).isEqualTo(1L);
+        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+    }
+
+    @Test
+    @DisplayName("when a qrCode is blank reject just the visit")
+    void blankQrCode() {
+        HttpEntity<ReportRequest> request = new HttpEntity<>(
+                new ReportRequest(List.of(new Visit("qr1", 1L), new Visit("     ", 2L)), 3L),
+                newJsonHeader()
+        );
+        ResponseEntity<String> response = restTemplate.postForEntity(pathPrefix + UriConstants.REPORT, request, String.class);
+        Mockito.verify(reportService).report(reportRequestArgumentCaptor.capture());
+        assertThat(reportRequestArgumentCaptor.getValue().getPivotDateAsNtpTimestamp()).isEqualTo(3L);
+        assertThat(reportRequestArgumentCaptor.getValue().getVisits().size()).isEqualTo(1);
+        assertThat(reportRequestArgumentCaptor.getValue().getVisits().stream().filter(it -> it.getQrCode().isBlank()).findAny()).isEmpty();
+        assertThat(reportRequestArgumentCaptor.getValue().getVisits().get(0).getQrCode()).isEqualTo("qr1");
+        assertThat(reportRequestArgumentCaptor.getValue().getVisits().get(0).getQrCodeScanTimeAsNtpTimestamp()).isEqualTo(1L);
+        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+    }
+
+    @Test
+    @DisplayName("when a qrScan is null reject just the visit")
+    void nullQrScan() {
+        HttpEntity<ReportRequest> request = new HttpEntity<>(
+                new ReportRequest(List.of(new Visit("qr1", 1L), new Visit("qr2", null)), 3L),
+                newJsonHeader()
+        );
+        ResponseEntity<String> response = restTemplate.postForEntity(pathPrefix + UriConstants.REPORT, request, String.class);
+        Mockito.verify(reportService).report(reportRequestArgumentCaptor.capture());
+        assertThat(reportRequestArgumentCaptor.getValue().getPivotDateAsNtpTimestamp()).isEqualTo(3L);
+        assertThat(reportRequestArgumentCaptor.getValue().getVisits().size()).isEqualTo(1);
+        assertThat(reportRequestArgumentCaptor.getValue().getVisits().stream().filter(it -> it.getQrCodeScanTimeAsNtpTimestamp() == null).findAny()).isEmpty();
+        assertThat(reportRequestArgumentCaptor.getValue().getVisits().get(0).getQrCode()).isEqualTo("qr1");
+        assertThat(reportRequestArgumentCaptor.getValue().getVisits().get(0).getQrCodeScanTimeAsNtpTimestamp()).isEqualTo(1L);
+        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+    }
+
+    @Test
+    @DisplayName("when a qrScan is not numeric reject everything")
+    void notNumericQrScan() throws JsonProcessingException {
+        ReportRequest reportRequest = new ReportRequest(List.of(new Visit("qr1", 1L), new Visit("qr2", 2L)), 3L);
+        String json = objectMapper.writeValueAsString(reportRequest);
+        String badJson = json.replace("2", "a");
+        HttpEntity<String> request = new HttpEntity<>(badJson, newJsonHeader());
+        ResponseEntity<String> response = restTemplate.postForEntity(pathPrefix + UriConstants.REPORT, request, String.class);
+        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+        verifyNoMoreInteractions(reportService);
+    }
+}
diff --git a/clea/clea-ws-rest/src/test/java/fr/gouv/clea/ws/service/impl/AuthorizationServiceTest.java b/clea/clea-ws-rest/src/test/java/fr/gouv/clea/ws/service/impl/AuthorizationServiceTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..bed92e62f238b7cfea560f36ae2f9de1164d44d5
--- /dev/null
+++ b/clea/clea-ws-rest/src/test/java/fr/gouv/clea/ws/service/impl/AuthorizationServiceTest.java
@@ -0,0 +1,84 @@
+package fr.gouv.clea.ws.service.impl;
+
+import fr.gouv.clea.ws.exception.CleaUnauthorizedException;
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.SignatureAlgorithm;
+import io.jsonwebtoken.io.Encoders;
+import io.jsonwebtoken.security.Keys;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.security.KeyPair;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.Date;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.assertj.core.api.Assertions.fail;
+
+class AuthorizationServiceTest {
+
+    private AuthorizationService authorizationService;
+
+    private AuthorizationService disabledAuthorizationService;
+
+    private KeyPair keyPair;
+
+    @BeforeEach
+    void init() {
+        keyPair = Keys.keyPairFor(SignatureAlgorithm.RS256);
+        String jwtPublicKey = Encoders.BASE64.encode(keyPair.getPublic().getEncoded());
+        authorizationService = new AuthorizationService(true, jwtPublicKey);
+        disabledAuthorizationService = new AuthorizationService(false, jwtPublicKey);
+    }
+
+    @Test
+    @DisplayName("if auth is activated in conf, null header should throw CleaUnauthorizedException")
+    void testGiventAuthActivatedAndNullTokenThenCleaUnauthorizedExpcetionThrown() {
+        assertThatExceptionOfType(CleaUnauthorizedException.class)
+                .isThrownBy(() -> authorizationService.checkAuthorization(null));
+    }
+
+    @Test
+    @DisplayName("if auth is deactivated in conf, null header should have no impact")
+    void testGiventAuthDeactivatedAndNullTokenThenNoExceptionThrown() {
+        try {
+            disabledAuthorizationService.checkAuthorization(null);
+        } catch (Exception e) {
+            fail("if auth is deactivated in conf, null header should have no impact");
+        }
+    }
+
+    @Test
+    void testGivenAnInvalidJwtBearerWhenRequestingAuthorizationThenAuthorizationFails() {
+        assertThatExceptionOfType(CleaUnauthorizedException.class)
+                .isThrownBy(() -> authorizationService.checkAuthorization("unauthorized"));
+    }
+
+    @Test
+    void testGivenAValidJwtBearerWhenRequestingAuthorizationThenAuthorizationSucceeds() {
+        long jwtLifeTime = 5;
+        Instant now = Instant.now();
+        Instant expiration = now.plus(jwtLifeTime, ChronoUnit.MINUTES);
+        boolean isAuthorized = authorizationService.checkAuthorization(this.newJwtToken(now, expiration));
+        assertThat(isAuthorized).isTrue();
+    }
+
+    @Test
+    public void testGivenAValidJwtBearerAlreadyExpiredWhenRequestingAuthorizationThenAuthorizationFails() {
+        Instant now = Instant.now();
+        assertThatExceptionOfType(CleaUnauthorizedException.class)
+                .isThrownBy(() -> authorizationService.checkAuthorization(this.newJwtToken(now, now)));
+    }
+
+    protected String newJwtToken(Instant now, Instant expiration) {
+        return Jwts.builder()
+                .setHeaderParam("type", "JWT")
+                .setIssuedAt(Date.from(now))
+                .setExpiration(Date.from(expiration))
+                .signWith(keyPair.getPrivate(), SignatureAlgorithm.RS256)
+                .compact();
+    }
+}
diff --git a/clea/clea-ws-rest/src/test/java/fr/gouv/clea/ws/service/impl/DecodedVisitProducerServiceTest.java b/clea/clea-ws-rest/src/test/java/fr/gouv/clea/ws/service/impl/DecodedVisitProducerServiceTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..62989fac3a55e44f49c868e1d60ce444ef5df10a
--- /dev/null
+++ b/clea/clea-ws-rest/src/test/java/fr/gouv/clea/ws/service/impl/DecodedVisitProducerServiceTest.java
@@ -0,0 +1,135 @@
+package fr.gouv.clea.ws.service.impl;
+
+import fr.gouv.clea.ws.model.DecodedVisit;
+import fr.gouv.clea.ws.service.IDecodedVisitProducerService;
+import fr.gouv.clea.ws.utils.KafkaDeserializer;
+import fr.inria.clea.lsp.EncryptedLocationSpecificPart;
+import org.apache.commons.lang3.RandomUtils;
+import org.apache.kafka.clients.consumer.Consumer;
+import org.apache.kafka.clients.consumer.ConsumerRecord;
+import org.apache.kafka.clients.consumer.ConsumerRecords;
+import org.apache.kafka.common.serialization.StringDeserializer;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.kafka.core.DefaultKafkaConsumerFactory;
+import org.springframework.kafka.test.EmbeddedKafkaBroker;
+import org.springframework.kafka.test.context.EmbeddedKafka;
+import org.springframework.kafka.test.utils.KafkaTestUtils;
+import org.springframework.test.annotation.DirtiesContext;
+import org.springframework.test.context.TestPropertySource;
+
+import java.time.Instant;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import java.util.stream.Collectors;
+import java.util.stream.StreamSupport;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@SpringBootTest
+@DirtiesContext
+@EmbeddedKafka(partitions = 1, brokerProperties = {"listeners=PLAINTEXT://localhost:9092", "port=9092"})
+@TestPropertySource(properties = {"kafka.bootstrapAddress=localhost:9092"})
+class DecodedVisitProducerServiceTest {
+
+    @Autowired
+    private IDecodedVisitProducerService decodedVisitProducerService;
+
+    @Autowired
+    private EmbeddedKafkaBroker embeddedKafkaBroker;
+
+    private Consumer<String, DecodedVisit> consumer;
+
+    @Value("${spring.kafka.template.default-topic}")
+    private String defaultTopic;
+
+    private static DecodedVisit createSerializableDecodedVisit(Instant qrCodeScanTime, boolean isBackward, UUID locationTemporaryPublicId, byte[] encryptedLocationMessage) {
+        return new DecodedVisit(
+                qrCodeScanTime,
+                EncryptedLocationSpecificPart.builder()
+                        .version(RandomUtils.nextInt())
+                        .type(RandomUtils.nextInt())
+                        .locationTemporaryPublicId(locationTemporaryPublicId)
+                        .encryptedLocationMessage(encryptedLocationMessage)
+                        .build(),
+                isBackward
+        );
+    }
+
+    @BeforeEach
+    void init() {
+        Map<String, Object> configs = new HashMap<>(KafkaTestUtils.consumerProps("consumer", "false", embeddedKafkaBroker));
+        consumer = new DefaultKafkaConsumerFactory<>(configs, new StringDeserializer(), new KafkaDeserializer()).createConsumer();
+        consumer.subscribe(Collections.singleton(defaultTopic));
+    }
+
+    @Test
+    @DisplayName("test that produce send decoded lsps to kafka and that we can read them back")
+    void testProduce() {
+        UUID uuid1 = UUID.randomUUID();
+        UUID uuid2 = UUID.randomUUID();
+        UUID uuid3 = UUID.randomUUID();
+
+        byte[] encryptedLocationMessage1 = RandomUtils.nextBytes(21);
+        byte[] encryptedLocationMessage2 = RandomUtils.nextBytes(22);
+        byte[] encryptedLocationMessage3 = RandomUtils.nextBytes(23);
+
+        boolean isBackward1 = RandomUtils.nextBoolean();
+        boolean isBackward2 = RandomUtils.nextBoolean();
+        boolean isBackward3 = RandomUtils.nextBoolean();
+
+        Instant qrCodeScanTime1 = newRandomInstant();
+        Instant qrCodeScanTime2 = newRandomInstant();
+        Instant qrCodeScanTime3 = newRandomInstant();
+
+        List<DecodedVisit> decoded = List.of(
+                createSerializableDecodedVisit(qrCodeScanTime1, isBackward1, uuid1, encryptedLocationMessage1),
+                createSerializableDecodedVisit(qrCodeScanTime2, isBackward2, uuid2, encryptedLocationMessage2),
+                createSerializableDecodedVisit(qrCodeScanTime3, isBackward3, uuid3, encryptedLocationMessage3)
+        );
+
+        decodedVisitProducerService.produce(decoded);
+
+        ConsumerRecords<String, DecodedVisit> records = KafkaTestUtils.getRecords(consumer);
+        assertThat(records.count()).isEqualTo(3);
+
+        List<DecodedVisit> extracted = StreamSupport
+                .stream(records.spliterator(), true)
+                .map(ConsumerRecord::value)
+                .collect(Collectors.toList());
+        assertThat(extracted.size()).isEqualTo(3);
+
+        DecodedVisit visit1 = extracted.stream().filter(it -> it.getLocationTemporaryPublicId().equals(uuid1)).findFirst().orElse(null);
+        assertThat(visit1).isNotNull();
+        assertThat(visit1.getLocationTemporaryPublicId()).isEqualTo(uuid1);
+        assertThat(visit1.getEncryptedLocationSpecificPart().getEncryptedLocationMessage()).isEqualTo(encryptedLocationMessage1);
+        assertThat(visit1.getQrCodeScanTime()).isEqualTo(qrCodeScanTime1);
+        assertThat(visit1.isBackward()).isEqualTo(isBackward1);
+
+        DecodedVisit visit2 = extracted.stream().filter(it -> it.getLocationTemporaryPublicId().equals(uuid2)).findFirst().orElse(null);
+        assertThat(visit2).isNotNull();
+        assertThat(visit2.getLocationTemporaryPublicId()).isEqualTo(uuid2);
+        assertThat(visit2.getEncryptedLocationSpecificPart().getEncryptedLocationMessage()).isEqualTo(encryptedLocationMessage2);
+        assertThat(visit2.getQrCodeScanTime()).isEqualTo(qrCodeScanTime2);
+        assertThat(visit2.isBackward()).isEqualTo(isBackward2);
+
+        DecodedVisit visit3 = extracted.stream().filter(it -> it.getLocationTemporaryPublicId().equals(uuid3)).findFirst().orElse(null);
+        assertThat(visit3).isNotNull();
+        assertThat(visit3.getLocationTemporaryPublicId()).isEqualTo(uuid3);
+        assertThat(visit3.getEncryptedLocationSpecificPart().getEncryptedLocationMessage()).isEqualTo(encryptedLocationMessage3);
+        assertThat(visit3.getQrCodeScanTime()).isEqualTo(qrCodeScanTime3);
+        assertThat(visit3.isBackward()).isEqualTo(isBackward3);
+    }
+
+    protected Instant newRandomInstant() {
+        return Instant.ofEpochSecond(RandomUtils.nextLong(0, Instant.now().getEpochSecond()));
+    }
+
+}
\ No newline at end of file
diff --git a/clea/clea-ws-rest/src/test/java/fr/gouv/clea/ws/service/impl/ReportServiceTest.java b/clea/clea-ws-rest/src/test/java/fr/gouv/clea/ws/service/impl/ReportServiceTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..21c4c22dcec6722f6cd4531400ac97908fe33950
--- /dev/null
+++ b/clea/clea-ws-rest/src/test/java/fr/gouv/clea/ws/service/impl/ReportServiceTest.java
@@ -0,0 +1,164 @@
+package fr.gouv.clea.ws.service.impl;
+
+import fr.gouv.clea.ws.model.DecodedVisit;
+import fr.gouv.clea.ws.service.IDecodedVisitProducerService;
+import fr.gouv.clea.ws.service.IReportService;
+import fr.gouv.clea.ws.vo.ReportRequest;
+import fr.gouv.clea.ws.vo.Visit;
+import fr.inria.clea.lsp.CleaEncodingException;
+import fr.inria.clea.lsp.EncryptedLocationSpecificPart;
+import fr.inria.clea.lsp.LocationSpecificPart;
+import fr.inria.clea.lsp.LocationSpecificPartDecoder;
+import fr.inria.clea.lsp.LocationSpecificPartEncoder;
+import fr.inria.clea.lsp.utils.TimeUtils;
+import org.apache.tomcat.util.codec.binary.Base64;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.List;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class ReportServiceTest {
+
+    private final int retentionDuration = 14;
+    private final long duplicateScanThresholdInSeconds = 10800L;
+    private final LocationSpecificPartDecoder decoder = mock(LocationSpecificPartDecoder.class);
+    private final IDecodedVisitProducerService processService = mock(IDecodedVisitProducerService.class);
+    private final IReportService reportService = new ReportService(retentionDuration, duplicateScanThresholdInSeconds, decoder, processService);
+    private Instant now;
+
+    @BeforeEach
+    void init() {
+        now = Instant.now();
+        assertThat(decoder).isNotNull();
+        assertThat(processService).isNotNull();
+        assertThat(reportService).isNotNull();
+        doNothing().when(processService).produce(anyList());
+    }
+
+    @Test
+    @DisplayName("test successful report with no rejection")
+    void report() throws CleaEncodingException {
+        UUID uuid1 = UUID.randomUUID();
+        UUID uuid2 = UUID.randomUUID();
+        UUID uuid3 = UUID.randomUUID();
+        List<Visit> visits = List.of(
+                newVisit(uuid1, TimeUtils.ntpTimestampFromInstant(now.minus(2, ChronoUnit.DAYS))), // pass
+                newVisit(uuid2, TimeUtils.ntpTimestampFromInstant(now.minus(1, ChronoUnit.DAYS))), // pass
+                newVisit(uuid3, TimeUtils.ntpTimestampFromInstant(now)) /* pass */);
+
+        List<DecodedVisit> processed = reportService.report(new ReportRequest(visits, 0L));
+
+        assertThat(processed.size()).isEqualTo(3);
+        assertThat(processed.stream().filter(it -> it.getLocationTemporaryPublicId().equals(uuid1)).findAny()).isPresent();
+        assertThat(processed.stream().filter(it -> it.getLocationTemporaryPublicId().equals(uuid2)).findAny()).isPresent();
+        assertThat(processed.stream().filter(it -> it.getLocationTemporaryPublicId().equals(uuid3)).findAny()).isPresent();
+    }
+
+    @Test
+    @DisplayName("test report with non valid qr codes")
+    void testWithNonValidReports() throws CleaEncodingException {
+        UUID uuid1 = UUID.randomUUID();
+        UUID uuid2 = UUID.randomUUID();
+        UUID uuid3 = UUID.randomUUID();
+        List<Visit> visits = List.of(
+                newVisit(uuid1, TimeUtils.ntpTimestampFromInstant(now.minus(1, ChronoUnit.DAYS))), // pass
+                newVisit(uuid2, TimeUtils.ntpTimestampFromInstant(now)), // pass
+                newVisit(uuid3, TimeUtils.ntpTimestampFromInstant(now.plus(1, ChronoUnit.DAYS))) /* don't pass */);
+
+
+        List<DecodedVisit> processed = reportService.report(new ReportRequest(visits, 0L));
+
+        assertThat(processed.size()).isEqualTo(2);
+        assertThat(processed.stream().filter(it -> it.getLocationTemporaryPublicId().equals(uuid1)).findAny()).isPresent();
+        assertThat(processed.stream().filter(it -> it.getLocationTemporaryPublicId().equals(uuid2)).findAny()).isPresent();
+        assertThat(processed.stream().filter(it -> it.getLocationTemporaryPublicId().equals(uuid3)).findAny()).isNotPresent();
+    }
+
+    @Test
+    @DisplayName("test report with outdated scans")
+    void testWithOutdatedReports() throws CleaEncodingException {
+        UUID uuid1 = UUID.randomUUID();
+        UUID uuid2 = UUID.randomUUID();
+        UUID uuid3 = UUID.randomUUID();
+        UUID uuid4 = UUID.randomUUID();
+        List<Visit> visits = List.of(
+                newVisit(uuid1, TimeUtils.ntpTimestampFromInstant(now.minus(15, ChronoUnit.DAYS))), // don't pass
+                newVisit(uuid2, TimeUtils.ntpTimestampFromInstant(now.minus(14, ChronoUnit.DAYS))), // pass
+                newVisit(uuid3, TimeUtils.ntpTimestampFromInstant(now.minus(2, ChronoUnit.DAYS))), // pass
+                newVisit(uuid4, TimeUtils.ntpTimestampFromInstant(now)) /* pass */);
+
+
+        List<DecodedVisit> processed = reportService.report(new ReportRequest(visits, 0L));
+
+        assertThat(processed.size()).isEqualTo(3);
+        assertThat(processed.stream().filter(it -> it.getLocationTemporaryPublicId().equals(uuid1)).findAny()).isNotPresent();
+        assertThat(processed.stream().filter(it -> it.getLocationTemporaryPublicId().equals(uuid2)).findAny()).isPresent();
+        assertThat(processed.stream().filter(it -> it.getLocationTemporaryPublicId().equals(uuid3)).findAny()).isPresent();
+        assertThat(processed.stream().filter(it -> it.getLocationTemporaryPublicId().equals(uuid4)).findAny()).isPresent();
+    }
+
+    @Test
+    @DisplayName("test report with future scans")
+    void testWithFutureReports() throws CleaEncodingException {
+        UUID uuid1 = UUID.randomUUID();
+        UUID uuid2 = UUID.randomUUID();
+        List<Visit> visits = List.of(
+                newVisit(uuid1, TimeUtils.ntpTimestampFromInstant(now)), // pass
+                newVisit(uuid2, TimeUtils.ntpTimestampFromInstant(now.plus(1, ChronoUnit.SECONDS))) /* don't pass */);
+
+        List<DecodedVisit> processed = reportService.report(new ReportRequest(visits, 0L));
+
+        assertThat(processed.size()).isEqualTo(1);
+        assertThat(processed.stream().filter(it -> it.getLocationTemporaryPublicId().equals(uuid1)).findAny()).isPresent();
+        assertThat(processed.stream().filter(it -> it.getLocationTemporaryPublicId().equals(uuid2)).findAny()).isNotPresent();
+    }
+
+    @Test
+    @DisplayName("test report with duplicated qr codes")
+    void testWithDuplicates() throws CleaEncodingException {
+        UUID uuidA = UUID.randomUUID();
+        UUID uuidB = UUID.randomUUID();
+        UUID uuidC = UUID.randomUUID();
+        List<Visit> visits = List.of(
+                newVisit(uuidA, TimeUtils.ntpTimestampFromInstant(now.minus(4, ChronoUnit.HOURS))), // pass
+                newVisit(uuidA, TimeUtils.ntpTimestampFromInstant(now)), // pass
+                newVisit(uuidB, TimeUtils.ntpTimestampFromInstant(now.minus(3, ChronoUnit.HOURS))), // pass
+                newVisit(uuidB, TimeUtils.ntpTimestampFromInstant(now)), // don't pass
+                newVisit(uuidC, TimeUtils.ntpTimestampFromInstant(now)), // pass
+                newVisit(uuidC, TimeUtils.ntpTimestampFromInstant(now)) /* don't pass */);
+
+        List<DecodedVisit> processed = reportService.report(new ReportRequest(visits, 0L));
+
+        assertThat(processed.size()).isEqualTo(4);
+        assertThat(processed.stream().filter(it -> it.getLocationTemporaryPublicId().equals(uuidA)).count()).isEqualTo(2);
+        assertThat(processed.stream().filter(it -> it.getLocationTemporaryPublicId().equals(uuidB)).count()).isEqualTo(1);
+        assertThat(processed.stream().filter(it -> it.getLocationTemporaryPublicId().equals(uuidC)).count()).isEqualTo(1);
+    }
+
+    private EncryptedLocationSpecificPart createEncryptedLocationSpecificPart(UUID locationTemporaryPublicId) {
+        return EncryptedLocationSpecificPart.builder()
+                .locationTemporaryPublicId(locationTemporaryPublicId)
+                .build();
+    }
+
+    private Visit newVisit(UUID uuid, Long qrCodeScanTime) throws CleaEncodingException {
+        LocationSpecificPart lsp = LocationSpecificPart.builder()
+                .locationTemporaryPublicId(uuid)
+                .build();
+        byte[] qrCodeHeader = new LocationSpecificPartEncoder(null).binaryEncodedHeader(lsp);
+        String qrCode = Base64.encodeBase64String(qrCodeHeader);
+        when(decoder.decodeHeader(qrCodeHeader)).thenReturn(createEncryptedLocationSpecificPart(uuid));
+        return new Visit(qrCode, qrCodeScanTime);
+    }
+
+}
\ No newline at end of file
diff --git a/clea/clea-ws-rest/src/test/java/fr/gouv/clea/ws/utils/KafkaSerializerTest.java b/clea/clea-ws-rest/src/test/java/fr/gouv/clea/ws/utils/KafkaSerializerTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..1aa24517d4a564fcb041f9d6f987d897a7ec0b7a
--- /dev/null
+++ b/clea/clea-ws-rest/src/test/java/fr/gouv/clea/ws/utils/KafkaSerializerTest.java
@@ -0,0 +1,41 @@
+package fr.gouv.clea.ws.utils;
+
+import fr.gouv.clea.ws.model.DecodedVisit;
+import fr.inria.clea.lsp.EncryptedLocationSpecificPart;
+import org.apache.commons.lang3.RandomUtils;
+import org.junit.jupiter.api.Test;
+
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class KafkaSerializerTest {
+
+    @Test
+    void testCanSerializeAndDeserializeAVisit() {
+        DecodedVisit decoded = new DecodedVisit(
+                Instant.now().truncatedTo(ChronoUnit.SECONDS),
+                EncryptedLocationSpecificPart.builder()
+                        .version(RandomUtils.nextInt())
+                        .type(RandomUtils.nextInt())
+                        .locationTemporaryPublicId(UUID.randomUUID())
+                        .encryptedLocationMessage(RandomUtils.nextBytes(20))
+                        .build(),
+                RandomUtils.nextBoolean()
+        );
+        KafkaSerializer serializer = new KafkaSerializer();
+        KafkaDeserializer deserializer = new KafkaDeserializer();
+
+        byte[] serializedVisit = serializer.serialize("", decoded);
+        DecodedVisit deserializedVisit = deserializer.deserialize("", serializedVisit);
+
+        assertThat(decoded.getQrCodeScanTime().truncatedTo(ChronoUnit.SECONDS)).isEqualTo(deserializedVisit.getQrCodeScanTime().truncatedTo(ChronoUnit.SECONDS));
+        assertThat(decoded.isBackward()).isEqualTo(deserializedVisit.isBackward());
+        assertThat(decoded.getEncryptedLocationSpecificPart()).isEqualTo(deserializedVisit.getEncryptedLocationSpecificPart());
+        serializer.close();
+        deserializer.close();
+    }
+
+}
\ No newline at end of file
diff --git a/clea/clea-ws-rest/src/test/resources/application.properties b/clea/clea-ws-rest/src/test/resources/application.properties
new file mode 100644
index 0000000000000000000000000000000000000000..d436a313295e831c319a835b75aea5da046f922c
--- /dev/null
+++ b/clea/clea-ws-rest/src/test/resources/application.properties
@@ -0,0 +1,2 @@
+# For Unit-test, remove need for a JWT
+clea.conf.security.report.checkAuthorization=false
\ No newline at end of file
diff --git a/clea/docker-compose.yml b/clea/docker-compose.yml
new file mode 100644
index 0000000000000000000000000000000000000000..ef8bc6901443b23a06daabb3384805cb53bf1cdc
--- /dev/null
+++ b/clea/docker-compose.yml
@@ -0,0 +1,113 @@
+services:
+
+  clea-ws-rest:
+    container_name: clea-ws-rest
+    image: clea-ws-rest:latest
+    build: ./clea-ws-rest/
+    environment:
+      SPRING_PROFILES_ACTIVE: docker
+      KAFKA_URL: kafka:29092
+    depends_on:
+      - kafka
+    ports:
+      - "8080:8080"
+    networks:
+      - clea-network
+    restart: always
+
+  clea-venue-consumer:
+    container_name: clea-venue-consumer
+    image: clea-venue-consumer:latest
+    build: ./clea-venue-consumer/
+    environment:
+      SPRING_PROFILES_ACTIVE: docker
+      DB_URL: jdbc:postgresql://postgres:5432/cleadb
+      DB_USER: user
+      DB_PASSWORD: pass
+      KAFKA_URL: kafka:29092
+    depends_on:
+      - postgres
+      - kafka
+    ports:
+      - "7070:8080"
+    networks:
+      - clea-network
+    restart: always
+
+  kafka:
+    container_name: kafka
+    image: "wurstmeister/kafka:latest"
+    environment:
+      KAFKA_INTER_BROKER_LISTENER_NAME: "INTERNAL"
+      KAFKA_LISTENERS: "INTERNAL://:29092,EXTERNAL://:9092"
+      KAFKA_ADVERTISED_LISTENERS: "INTERNAL://kafka:29092,EXTERNAL://localhost:9092"
+      KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: "INTERNAL:PLAINTEXT,EXTERNAL:PLAINTEXT"
+      KAFKA_AUTO_CREATE_TOPICS_ENABLE: "true"
+      KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
+    depends_on:
+      - zookeeper
+    ports:
+      - "9092:9092"
+    volumes:
+      - /var/run/docker.sock:/var/run/docker.sock
+    networks:
+      - clea-network
+    restart: always
+
+  zookeeper:
+    container_name: zookeeper
+    image: wurstmeister/zookeeper:latest
+    ports:
+      - "2181:2181"
+    networks:
+      - clea-network
+    restart: always
+
+  kafdrop:
+    container_name: kafdrop
+    image: "obsidiandynamics/kafdrop:latest"
+    environment:
+      JVM_OPTS: "-Xms16M -Xmx48M -Xss180K -XX:-TieredCompilation -XX:+UseStringDeduplication -noverify"
+      KAFKA_BROKERCONNECT: "kafka:29092"
+    depends_on:
+      - kafka
+    ports:
+      - "9000:9000"
+    networks:
+      - clea-network
+    restart: always
+
+  postgres:
+    container_name: postgres
+    image: "postgres:latest"
+    environment:
+      POSTGRES_DB: cleadb
+      POSTGRES_PASSWORD: pass
+      POSTGRES_USER: user
+    ports:
+      - "5432:5432"
+    networks:
+      - clea-network
+    restart: always
+
+  pgadmin:
+    container_name: pgadmin
+    image: "dpage/pgadmin4:5.1"
+    environment:
+      PGADMIN_DEFAULT_EMAIL: user@pgadmin.com
+      PGADMIN_DEFAULT_PASSWORD: pass
+      PGADMIN_LISTEN_PORT: 80
+    depends_on:
+      - postgres
+    ports:
+      - "8081:80"
+    networks:
+      - clea-network
+    restart: always
+    logging:
+      driver: none
+
+networks:
+  clea-network:
+    driver: bridge
+    name: clea-network
diff --git a/clea/mvnw b/clea/mvnw
new file mode 100755
index 0000000000000000000000000000000000000000..3c8a5537314954d53ec2fb774b34fe5d5a5f253a
--- /dev/null
+++ b/clea/mvnw
@@ -0,0 +1,322 @@
+#!/bin/sh
+# ----------------------------------------------------------------------------
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#    https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+# ----------------------------------------------------------------------------
+
+# ----------------------------------------------------------------------------
+# Maven Start Up Batch script
+#
+# Required ENV vars:
+# ------------------
+#   JAVA_HOME - location of a JDK home dir
+#
+# Optional ENV vars
+# -----------------
+#   M2_HOME - location of maven2's installed home dir
+#   MAVEN_OPTS - parameters passed to the Java VM when running Maven
+#     e.g. to debug Maven itself, use
+#       set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000
+#   MAVEN_SKIP_RC - flag to disable loading of mavenrc files
+# ----------------------------------------------------------------------------
+
+if [ -z "$MAVEN_SKIP_RC" ]; then
+
+  if [ -f /etc/mavenrc ]; then
+    . /etc/mavenrc
+  fi
+
+  if [ -f "$HOME/.mavenrc" ]; then
+    . "$HOME/.mavenrc"
+  fi
+
+fi
+
+# OS specific support.  $var _must_ be set to either true or false.
+cygwin=false
+darwin=false
+mingw=false
+case "$(uname)" in
+CYGWIN*) cygwin=true ;;
+MINGW*) mingw=true ;;
+Darwin*)
+  darwin=true
+  # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home
+  # See https://developer.apple.com/library/mac/qa/qa1170/_index.html
+  if [ -z "$JAVA_HOME" ]; then
+    if [ -x "/usr/libexec/java_home" ]; then
+      export JAVA_HOME="$(/usr/libexec/java_home)"
+    else
+      export JAVA_HOME="/Library/Java/Home"
+    fi
+  fi
+  ;;
+esac
+
+if [ -z "$JAVA_HOME" ]; then
+  if [ -r /etc/gentoo-release ]; then
+    JAVA_HOME=$(java-config --jre-home)
+  fi
+fi
+
+if [ -z "$M2_HOME" ]; then
+  ## resolve links - $0 may be a link to maven's home
+  PRG="$0"
+
+  # need this for relative symlinks
+  while [ -h "$PRG" ]; do
+    ls=$(ls -ld "$PRG")
+    link=$(expr "$ls" : '.*-> \(.*\)$')
+    if expr "$link" : '/.*' >/dev/null; then
+      PRG="$link"
+    else
+      PRG="$(dirname "$PRG")/$link"
+    fi
+  done
+
+  saveddir=$(pwd)
+
+  M2_HOME=$(dirname "$PRG")/..
+
+  # make it fully qualified
+  M2_HOME=$(cd "$M2_HOME" && pwd)
+
+  cd "$saveddir"
+  # echo Using m2 at $M2_HOME
+fi
+
+# For Cygwin, ensure paths are in UNIX format before anything is touched
+if $cygwin; then
+  [ -n "$M2_HOME" ] &&
+    M2_HOME=$(cygpath --unix "$M2_HOME")
+  [ -n "$JAVA_HOME" ] &&
+    JAVA_HOME=$(cygpath --unix "$JAVA_HOME")
+  [ -n "$CLASSPATH" ] &&
+    CLASSPATH=$(cygpath --path --unix "$CLASSPATH")
+fi
+
+# For Mingw, ensure paths are in UNIX format before anything is touched
+if $mingw; then
+  [ -n "$M2_HOME" ] &&
+    M2_HOME="$( (
+      cd "$M2_HOME"
+      pwd
+    ))"
+  [ -n "$JAVA_HOME" ] &&
+    JAVA_HOME="$( (
+      cd "$JAVA_HOME"
+      pwd
+    ))"
+fi
+
+if [ -z "$JAVA_HOME" ]; then
+  javaExecutable="$(which javac)"
+  if [ -n "$javaExecutable" ] && ! [ "$(expr \"$javaExecutable\" : '\([^ ]*\)')" = "no" ]; then
+    # readlink(1) is not available as standard on Solaris 10.
+    readLink=$(which readlink)
+    if [ ! $(expr "$readLink" : '\([^ ]*\)') = "no" ]; then
+      if $darwin; then
+        javaHome="$(dirname \"$javaExecutable\")"
+        javaExecutable="$(cd \"$javaHome\" && pwd -P)/javac"
+      else
+        javaExecutable="$(readlink -f \"$javaExecutable\")"
+      fi
+      javaHome="$(dirname \"$javaExecutable\")"
+      javaHome=$(expr "$javaHome" : '\(.*\)/bin')
+      JAVA_HOME="$javaHome"
+      export JAVA_HOME
+    fi
+  fi
+fi
+
+if [ -z "$JAVACMD" ]; then
+  if [ -n "$JAVA_HOME" ]; then
+    if [ -x "$JAVA_HOME/jre/sh/java" ]; then
+      # IBM's JDK on AIX uses strange locations for the executables
+      JAVACMD="$JAVA_HOME/jre/sh/java"
+    else
+      JAVACMD="$JAVA_HOME/bin/java"
+    fi
+  else
+    JAVACMD="$(which java)"
+  fi
+fi
+
+if [ ! -x "$JAVACMD" ]; then
+  echo "Error: JAVA_HOME is not defined correctly." >&2
+  echo "  We cannot execute $JAVACMD" >&2
+  exit 1
+fi
+
+if [ -z "$JAVA_HOME" ]; then
+  echo "Warning: JAVA_HOME environment variable is not set."
+fi
+
+CLASSWORLDS_LAUNCHER=org.codehaus.plexus.classworlds.launcher.Launcher
+
+# traverses directory structure from process work directory to filesystem root
+# first directory with .mvn subdirectory is considered project base directory
+find_maven_basedir() {
+
+  if [ -z "$1" ]; then
+    echo "Path not specified to find_maven_basedir"
+    return 1
+  fi
+
+  basedir="$1"
+  wdir="$1"
+  while [ "$wdir" != '/' ]; do
+    if [ -d "$wdir"/.mvn ]; then
+      basedir=$wdir
+      break
+    fi
+    # workaround for JBEAP-8937 (on Solaris 10/Sparc)
+    if [ -d "${wdir}" ]; then
+      wdir=$(
+        cd "$wdir/.."
+        pwd
+      )
+    fi
+    # end of workaround
+  done
+  echo "${basedir}"
+}
+
+# concatenates all lines of a file
+concat_lines() {
+  if [ -f "$1" ]; then
+    echo "$(tr -s '\n' ' ' <"$1")"
+  fi
+}
+
+BASE_DIR=$(find_maven_basedir "$(pwd)")
+if [ -z "$BASE_DIR" ]; then
+  exit 1
+fi
+
+##########################################################################################
+# Extension to allow automatically downloading the maven-wrapper.jar from Maven-central
+# This allows using the maven wrapper in projects that prohibit checking in binary data.
+##########################################################################################
+if [ -r "$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" ]; then
+  if [ "$MVNW_VERBOSE" = true ]; then
+    echo "Found .mvn/wrapper/maven-wrapper.jar"
+  fi
+else
+  if [ "$MVNW_VERBOSE" = true ]; then
+    echo "Couldn't find .mvn/wrapper/maven-wrapper.jar, downloading it ..."
+  fi
+  if [ -n "$MVNW_REPOURL" ]; then
+    jarUrl="$MVNW_REPOURL/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar"
+  else
+    jarUrl="https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar"
+  fi
+  while IFS="=" read key value; do
+    case "$key" in wrapperUrl)
+      jarUrl="$value"
+      break
+      ;;
+    esac
+  done <"$BASE_DIR/.mvn/wrapper/maven-wrapper.properties"
+  if [ "$MVNW_VERBOSE" = true ]; then
+    echo "Downloading from: $jarUrl"
+  fi
+  wrapperJarPath="$BASE_DIR/.mvn/wrapper/maven-wrapper.jar"
+  if $cygwin; then
+    wrapperJarPath=$(cygpath --path --windows "$wrapperJarPath")
+  fi
+
+  if command -v wget >/dev/null; then
+    if [ "$MVNW_VERBOSE" = true ]; then
+      echo "Found wget ... using wget"
+    fi
+    if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then
+      wget "$jarUrl" -O "$wrapperJarPath"
+    else
+      wget --http-user=$MVNW_USERNAME --http-password=$MVNW_PASSWORD "$jarUrl" -O "$wrapperJarPath"
+    fi
+  elif command -v curl >/dev/null; then
+    if [ "$MVNW_VERBOSE" = true ]; then
+      echo "Found curl ... using curl"
+    fi
+    if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then
+      curl -o "$wrapperJarPath" "$jarUrl" -f
+    else
+      curl --user $MVNW_USERNAME:$MVNW_PASSWORD -o "$wrapperJarPath" "$jarUrl" -f
+    fi
+
+  else
+    if [ "$MVNW_VERBOSE" = true ]; then
+      echo "Falling back to using Java to download"
+    fi
+    javaClass="$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.java"
+    # For Cygwin, switch paths to Windows format before running javac
+    if $cygwin; then
+      javaClass=$(cygpath --path --windows "$javaClass")
+    fi
+    if [ -e "$javaClass" ]; then
+      if [ ! -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then
+        if [ "$MVNW_VERBOSE" = true ]; then
+          echo " - Compiling MavenWrapperDownloader.java ..."
+        fi
+        # Compiling the Java class
+        ("$JAVA_HOME/bin/javac" "$javaClass")
+      fi
+      if [ -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then
+        # Running the downloader
+        if [ "$MVNW_VERBOSE" = true ]; then
+          echo " - Running MavenWrapperDownloader.java ..."
+        fi
+        ("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$MAVEN_PROJECTBASEDIR")
+      fi
+    fi
+  fi
+fi
+##########################################################################################
+# End of extension
+##########################################################################################
+
+export MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"}
+if [ "$MVNW_VERBOSE" = true ]; then
+  echo $MAVEN_PROJECTBASEDIR
+fi
+MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS"
+
+# For Cygwin, switch paths to Windows format before running java
+if $cygwin; then
+  [ -n "$M2_HOME" ] &&
+    M2_HOME=$(cygpath --path --windows "$M2_HOME")
+  [ -n "$JAVA_HOME" ] &&
+    JAVA_HOME=$(cygpath --path --windows "$JAVA_HOME")
+  [ -n "$CLASSPATH" ] &&
+    CLASSPATH=$(cygpath --path --windows "$CLASSPATH")
+  [ -n "$MAVEN_PROJECTBASEDIR" ] &&
+    MAVEN_PROJECTBASEDIR=$(cygpath --path --windows "$MAVEN_PROJECTBASEDIR")
+fi
+
+# Provide a "standardized" way to retrieve the CLI args that will
+# work with both Windows and non-Windows executions.
+MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $@"
+export MAVEN_CMD_LINE_ARGS
+
+WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain
+
+exec "$JAVACMD" \
+  $MAVEN_OPTS \
+  -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \
+  "-Dmaven.home=${M2_HOME}" "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \
+  ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@"
diff --git a/clea/mvnw.cmd b/clea/mvnw.cmd
new file mode 100644
index 0000000000000000000000000000000000000000..c8d43372c986d97911cdc21bd87e0cbe3d83bdda
--- /dev/null
+++ b/clea/mvnw.cmd
@@ -0,0 +1,182 @@
+@REM ----------------------------------------------------------------------------
+@REM Licensed to the Apache Software Foundation (ASF) under one
+@REM or more contributor license agreements.  See the NOTICE file
+@REM distributed with this work for additional information
+@REM regarding copyright ownership.  The ASF licenses this file
+@REM to you under the Apache License, Version 2.0 (the
+@REM "License"); you may not use this file except in compliance
+@REM with the License.  You may obtain a copy of the License at
+@REM
+@REM    https://www.apache.org/licenses/LICENSE-2.0
+@REM
+@REM Unless required by applicable law or agreed to in writing,
+@REM software distributed under the License is distributed on an
+@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+@REM KIND, either express or implied.  See the License for the
+@REM specific language governing permissions and limitations
+@REM under the License.
+@REM ----------------------------------------------------------------------------
+
+@REM ----------------------------------------------------------------------------
+@REM Maven Start Up Batch script
+@REM
+@REM Required ENV vars:
+@REM JAVA_HOME - location of a JDK home dir
+@REM
+@REM Optional ENV vars
+@REM M2_HOME - location of maven2's installed home dir
+@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands
+@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending
+@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven
+@REM     e.g. to debug Maven itself, use
+@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000
+@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files
+@REM ----------------------------------------------------------------------------
+
+@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on'
+@echo off
+@REM set title of command window
+title %0
+@REM enable echoing by setting MAVEN_BATCH_ECHO to 'on'
+@if "%MAVEN_BATCH_ECHO%" == "on"  echo %MAVEN_BATCH_ECHO%
+
+@REM set %HOME% to equivalent of $HOME
+if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%")
+
+@REM Execute a user defined script before this one
+if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre
+@REM check for pre script, once with legacy .bat ending and once with .cmd ending
+if exist "%HOME%\mavenrc_pre.bat" call "%HOME%\mavenrc_pre.bat"
+if exist "%HOME%\mavenrc_pre.cmd" call "%HOME%\mavenrc_pre.cmd"
+:skipRcPre
+
+@setlocal
+
+set ERROR_CODE=0
+
+@REM To isolate internal variables from possible post scripts, we use another setlocal
+@setlocal
+
+@REM ==== START VALIDATION ====
+if not "%JAVA_HOME%" == "" goto OkJHome
+
+echo.
+echo Error: JAVA_HOME not found in your environment. >&2
+echo Please set the JAVA_HOME variable in your environment to match the >&2
+echo location of your Java installation. >&2
+echo.
+goto error
+
+:OkJHome
+if exist "%JAVA_HOME%\bin\java.exe" goto init
+
+echo.
+echo Error: JAVA_HOME is set to an invalid directory. >&2
+echo JAVA_HOME = "%JAVA_HOME%" >&2
+echo Please set the JAVA_HOME variable in your environment to match the >&2
+echo location of your Java installation. >&2
+echo.
+goto error
+
+@REM ==== END VALIDATION ====
+
+:init
+
+@REM Find the project base dir, i.e. the directory that contains the folder ".mvn".
+@REM Fallback to current working directory if not found.
+
+set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR%
+IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir
+
+set EXEC_DIR=%CD%
+set WDIR=%EXEC_DIR%
+:findBaseDir
+IF EXIST "%WDIR%"\.mvn goto baseDirFound
+cd ..
+IF "%WDIR%"=="%CD%" goto baseDirNotFound
+set WDIR=%CD%
+goto findBaseDir
+
+:baseDirFound
+set MAVEN_PROJECTBASEDIR=%WDIR%
+cd "%EXEC_DIR%"
+goto endDetectBaseDir
+
+:baseDirNotFound
+set MAVEN_PROJECTBASEDIR=%EXEC_DIR%
+cd "%EXEC_DIR%"
+
+:endDetectBaseDir
+
+IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig
+
+@setlocal EnableExtensions EnableDelayedExpansion
+for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a
+@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS%
+
+:endReadAdditionalConfig
+
+SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe"
+set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar"
+set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain
+
+set DOWNLOAD_URL="https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar"
+
+FOR /F "tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO (
+    IF "%%A"=="wrapperUrl" SET DOWNLOAD_URL=%%B
+)
+
+@REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central
+@REM This allows using the maven wrapper in projects that prohibit checking in binary data.
+if exist %WRAPPER_JAR% (
+    if "%MVNW_VERBOSE%" == "true" (
+        echo Found %WRAPPER_JAR%
+    )
+) else (
+    if not "%MVNW_REPOURL%" == "" (
+        SET DOWNLOAD_URL="%MVNW_REPOURL%/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar"
+    )
+    if "%MVNW_VERBOSE%" == "true" (
+        echo Couldn't find %WRAPPER_JAR%, downloading it ...
+        echo Downloading from: %DOWNLOAD_URL%
+    )
+
+    powershell -Command "&{"^
+		"$webclient = new-object System.Net.WebClient;"^
+		"if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^
+		"$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^
+		"}"^
+		"[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%DOWNLOAD_URL%', '%WRAPPER_JAR%')"^
+		"}"
+    if "%MVNW_VERBOSE%" == "true" (
+        echo Finished downloading %WRAPPER_JAR%
+    )
+)
+@REM End of extension
+
+@REM Provide a "standardized" way to retrieve the CLI args that will
+@REM work with both Windows and non-Windows executions.
+set MAVEN_CMD_LINE_ARGS=%*
+
+%MAVEN_JAVA_EXE% %JVM_CONFIG_MAVEN_PROPS% %MAVEN_OPTS% %MAVEN_DEBUG_OPTS% -classpath %WRAPPER_JAR% "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %*
+if ERRORLEVEL 1 goto error
+goto end
+
+:error
+set ERROR_CODE=1
+
+:end
+@endlocal & set ERROR_CODE=%ERROR_CODE%
+
+if not "%MAVEN_SKIP_RC%" == "" goto skipRcPost
+@REM check for post script, once with legacy .bat ending and once with .cmd ending
+if exist "%HOME%\mavenrc_post.bat" call "%HOME%\mavenrc_post.bat"
+if exist "%HOME%\mavenrc_post.cmd" call "%HOME%\mavenrc_post.cmd"
+:skipRcPost
+
+@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on'
+if "%MAVEN_BATCH_PAUSE%" == "on" pause
+
+if "%MAVEN_TERMINATE_CMD%" == "on" exit %ERROR_CODE%
+
+exit /B %ERROR_CODE%
diff --git a/clea/pom.xml b/clea/pom.xml
new file mode 100644
index 0000000000000000000000000000000000000000..f5362f2eda1067775e48a5be4d3c34f278e10add
--- /dev/null
+++ b/clea/pom.xml
@@ -0,0 +1,212 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xmlns="http://maven.apache.org/POM/4.0.0"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+
+    <licenses>
+        <license>
+            <name>Mozilla Public License, Version 2.0</name>
+            <url>https://www.mozilla.org/en-US/MPL/2.0/</url>
+        </license>
+    </licenses>
+
+    <parent>
+        <groupId>org.springframework.boot</groupId>
+        <artifactId>spring-boot-starter-parent</artifactId>
+        <version>2.4.3</version>
+        <relativePath/> <!-- lookup parent from repository -->
+    </parent>
+
+    <groupId>fr.gouv.clea</groupId>
+    <artifactId>clea-server</artifactId>
+    <version>0.1-SNAPSHOT</version>
+    <name>clea-server</name>
+    <packaging>pom</packaging>
+    <description>Tous Anti-Covid Cluster Exposure Verification (Clea) server project</description>
+
+    <modules>
+        <module>clea-ws-rest</module>
+        <module>clea-venue-consumer</module>
+        <module>clea-client</module>
+        <module>clea-qr-simulator</module>
+    </modules>
+
+    <properties>
+        <java.version>11</java.version>
+        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
+        <clea-crypto.version>0.0.1-SNAPSHOT</clea-crypto.version>
+        <maven.jar.plugin.version>3.2.0</maven.jar.plugin.version>
+        <postgresql.version>42.2.19</postgresql.version>
+        <spring-cloud.version>2020.0.1</spring-cloud.version>
+        <!--
+            inherited from spring-cloud-dependencies
+            <spring-kafka.version>2.6.6</spring-kafka.version>
+        -->
+        <springfox-boot-starter.version>3.0.0</springfox-boot-starter.version>
+        <mockito-core.version>3.8.0</mockito-core.version>
+        <assertj-core.version>3.19.0</assertj-core.version>
+        <awaitility.version>4.0.3</awaitility.version>
+        <commons-lang3.version>3.12.0</commons-lang3.version>
+        <kafka-embedded.version>1.15.2</kafka-embedded.version>
+        <maven-surefire-plugin.version>3.0.0-M5</maven-surefire-plugin.version>
+        <jacoco-maven-plugin.version>0.8.6</jacoco-maven-plugin.version>
+        <maven-site-plugin.version>3.7.1</maven-site-plugin.version>
+        <maven-jxr-plugin.version>2.1</maven-jxr-plugin.version>
+    </properties>
+
+    <dependencyManagement>
+        <dependencies>
+            <dependency>
+                <groupId>fr.inria.clea</groupId>
+                <artifactId>clea-crypto</artifactId>
+                <version>${clea-crypto.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>org.postgresql</groupId>
+                <artifactId>postgresql</artifactId>
+                <version>${postgresql.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>org.springframework.cloud</groupId>
+                <artifactId>spring-cloud-dependencies</artifactId>
+                <version>${spring-cloud.version}</version>
+                <type>pom</type>
+                <scope>import</scope>
+            </dependency>
+            <dependency>
+                <groupId>org.mockito</groupId>
+                <artifactId>mockito-core</artifactId>
+                <version>${mockito-core.version}</version>
+                <scope>test</scope>
+            </dependency>
+            <dependency>
+                <groupId>org.assertj</groupId>
+                <artifactId>assertj-core</artifactId>
+                <version>${assertj-core.version}</version>
+                <scope>test</scope>
+            </dependency>
+            <dependency>
+                <groupId>org.awaitility</groupId>
+                <artifactId>awaitility</artifactId>
+                <version>${awaitility.version}</version>
+                <scope>test</scope>
+            </dependency>
+            <dependency>
+                <groupId>org.apache.commons</groupId>
+                <artifactId>commons-lang3</artifactId>
+                <version>${commons-lang3.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>org.testcontainers</groupId>
+                <artifactId>kafka</artifactId>
+                <version>${kafka-embedded.version}</version>
+                <scope>test</scope>
+            </dependency>
+        </dependencies>
+    </dependencyManagement>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-surefire-plugin</artifactId>
+                <version>${maven-surefire-plugin.version}</version>
+            </plugin>
+        </plugins>
+        <pluginManagement>
+            <plugins>
+                <plugin>
+                    <groupId>org.springframework.boot</groupId>
+                    <artifactId>spring-boot-maven-plugin</artifactId>
+                    <configuration>
+                        <classifier>exec</classifier>
+                    </configuration>
+                </plugin>
+                <plugin>
+                    <groupId>org.apache.maven.plugins</groupId>
+                    <artifactId>maven-jar-plugin</artifactId>
+                    <version>${maven.jar.plugin.version}</version>
+                </plugin>
+            </plugins>
+        </pluginManagement>
+    </build>
+
+    <profiles>
+        <profile>
+            <id>report</id>
+            <activation>
+                <activeByDefault>true</activeByDefault>
+            </activation>
+            <build>
+                <plugins>
+                    <plugin>
+                        <groupId>org.jacoco</groupId>
+                        <artifactId>jacoco-maven-plugin</artifactId>
+                        <version>${jacoco-maven-plugin.version}</version>
+                    </plugin>
+                    <plugin>
+                        <groupId>org.apache.maven.plugins</groupId>
+                        <artifactId>maven-site-plugin</artifactId>
+                        <version>${maven-site-plugin.version}</version>
+                    </plugin>
+                    <plugin>
+                        <groupId>org.apache.maven.plugins</groupId>
+                        <artifactId>maven-surefire-plugin</artifactId>
+                        <version>${maven-surefire-plugin.version}</version>
+                    </plugin>
+                </plugins>
+            </build>
+            <reporting>
+                <plugins>
+                    <plugin>
+                        <groupId>org.apache.maven.plugins</groupId>
+                        <artifactId>maven-surefire-report-plugin</artifactId>
+                        <configuration>
+                            <aggregate>true</aggregate>
+                        </configuration>
+                    </plugin>
+                    <plugin>
+                        <groupId>org.apache.maven.plugins</groupId>
+                        <artifactId>maven-jxr-plugin</artifactId>
+                        <version>${maven-jxr-plugin.version}</version>
+                    </plugin>
+                    <plugin>
+                        <groupId>org.jacoco</groupId>
+                        <artifactId>jacoco-maven-plugin</artifactId>
+                    </plugin>
+                </plugins>
+            </reporting>
+        </profile>
+    </profiles>
+
+    <repositories>
+        <repository>
+            <id>gitlab-maven</id>
+            <url>${env.CI_SERVER_URL}/api/v4/projects/${env.CI_PROJECT_ID}/packages/maven</url>
+        </repository>
+        <repository>
+            <id>gitlab-maven-clea-crypto</id>
+            <url>${env.CI_SERVER_URL}/api/v4/projects/28288/packages/maven</url>
+        </repository>
+    </repositories>
+    <distributionManagement>
+        <repository>
+            <id>gitlab-maven</id>
+            <url>${env.CI_SERVER_URL}/api/v4/projects/${env.CI_PROJECT_ID}/packages/maven</url>
+        </repository>
+        <snapshotRepository>
+            <id>gitlab-maven</id>
+            <url>${env.CI_SERVER_URL}/api/v4/projects/${env.CI_PROJECT_ID}/packages/maven</url>
+        </snapshotRepository>
+    </distributionManagement>
+
+    <scm>
+        <connection>scm:git:${gitRepositoryUrl}</connection>
+        <developerConnection>scm:git:${gitRepositoryUrl}</developerConnection>
+        <url>${gitRepositoryUrl}</url>
+        <tag>HEAD</tag>
+    </scm>
+
+</project>
diff --git a/environment-setup/dev/compose/docker-compose-analytics-server.yaml b/environment-setup/dev/compose/docker-compose-analytics-server.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..a4af60bd2f398b284a5bd5897cf77da5e445794f
--- /dev/null
+++ b/environment-setup/dev/compose/docker-compose-analytics-server.yaml
@@ -0,0 +1,98 @@
+# This docker compose file allow to deploy all  mandatory components that are not easy to launch from your IDE of your
+# development laptop.
+
+version: '3.5'
+services:
+  mongoanalytics:
+    image: 'mongo:4.2.5'
+    ports:
+      - "27018:27017"
+  esanalytics:
+    image: docker.elastic.co/elasticsearch/elasticsearch:7.6.2
+    container_name: esanalytics
+    environment:
+      - node.name=esanalytics
+      - discovery.type=single-node
+      - cluster.name=elasticsearchanalytics
+      - "ES_JAVA_OPTS=-Xms512m -Xmx512m"
+    ulimits:
+      memlock:
+        soft: -1
+        hard: -1
+    volumes:
+      - esanalytics:/usr/share/elasticsearch/data/analytics
+    ports:
+      - 9200:9200
+    networks:
+      - network_analytics
+  kibanalytics:
+    image: docker.elastic.co/kibana/kibana:7.6.2
+    container_name: kibanalytics
+    ports:
+      - 5601:5601
+    environment:
+      ELASTICSEARCH_URL: http://esanalytics:9200
+      ELASTICSEARCH_HOSTS: '["http://esanalytics:9200"]'
+    networks:
+      - network_analytics
+    depends_on:
+      - esanalytics
+  logstashanalytics:
+    image: docker.elastic.co/logstash/logstash:7.6.2
+    volumes:
+      - type: bind
+        source: ./logstash/config/logstash.yml
+        target: /usr/share/logstash/config/logstash.yml
+        read_only: true
+      - type: bind
+        source: ./logstash/pipeline
+        target: /usr/share/logstash/pipeline
+        read_only: true
+    environment:
+      LS_JAVA_OPTS: "-Xmx512m -Xms512m"
+    networks:
+      - network_analytics
+    depends_on:
+      - esanalytics
+      - kafkaanalytics
+  zookeeperanalytics:
+    image: wurstmeister/zookeeper:3.4.6
+    ports:
+      - "2181:2181"
+    networks:
+      - network_analytics
+  kafkaanalytics:
+    image: wurstmeister/kafka:2.13-2.7.0
+    ports:
+      - "19092:19092"
+    environment:
+      HOSTNAME_COMMAND: "docker info | grep ^Name: | cut -d' ' -f 2"
+      KAFKA_LISTENERS: INSIDE://:9092,OUTSIDE://:19092
+      KAFKA_ADVERTISED_LISTENERS: INSIDE://kafkaanalytics:9092,OUTSIDE://_{HOSTNAME_COMMAND}:19092
+      KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: INSIDE:PLAINTEXT,OUTSIDE:PLAINTEXT
+      KAFKA_INTER_BROKER_LISTENER_NAME: INSIDE
+      KAFKA_AUTO_CREATE_TOPICS_ENABLE: "true"
+      KAFKA_ZOOKEEPER_CONNECT: zookeeperanalytics:2181
+    volumes:
+      - /var/run/docker.sock:/var/run/docker.sock
+    networks:
+      - network_analytics
+  kafdropanalytics:
+    image: obsidiandynamics/kafdrop:3.27.0
+    ports:
+      - "9000:9000"
+    environment:
+      KAFKA_BROKERCONNECT: "kafkaanalytics:19092"
+      JVM_OPTS: "-Xms128M -Xmx512M -Xss512K -XX:-TieredCompilation -XX:+UseStringDeduplication -noverify"
+    networks:
+      - network_analytics
+    depends_on:
+      - kafkaanalytics
+
+volumes:
+  esanalytics:
+    driver: local
+
+networks:
+  network_analytics:
+    driver: bridge
diff --git a/environment-setup/dev/compose/logstash/config/logstash.yml b/environment-setup/dev/compose/logstash/config/logstash.yml
new file mode 100644
index 0000000000000000000000000000000000000000..aa1904465dd9671a4f0220a1ff4b93d0f6d750cb
--- /dev/null
+++ b/environment-setup/dev/compose/logstash/config/logstash.yml
@@ -0,0 +1,8 @@
+---
+## Default Logstash configuration from Logstash base image.
+## https://github.com/elastic/logstash/blob/master/docker/data/logstash/config/logstash-full.yml
+#
+
+http.host: "0.0.0.0"
+xpack.monitoring.enabled: "false"
+log.level: info
diff --git a/environment-setup/dev/compose/logstash/pipeline/logstash.conf b/environment-setup/dev/compose/logstash/pipeline/logstash.conf
new file mode 100644
index 0000000000000000000000000000000000000000..5dc05c5ad4c7f07d596063670e8c8ffee77d44df
--- /dev/null
+++ b/environment-setup/dev/compose/logstash/pipeline/logstash.conf
@@ -0,0 +1,19 @@
+input {
+  kafka {
+    bootstrap_servers => ["kafkaanalytics:9092"]
+    topics => "analyticsTopic"
+    codec => json
+  }
+}
+
+## Add your filters / logstash plugins configuration here
+
+output {
+
+    stdout { }
+    elasticsearch {
+        hosts => "http://esanalytics:9200"
+        index => "analytics"
+     }
+
+}
diff --git a/environment-setup/integration/compose/.env.template b/environment-setup/integration/compose/.env.template
index 96e6dd78a00d86fc4e7796c073b3d82970cb600e..cb440c6836917381783a7cb7dc8040e35f973f19 100644
--- a/environment-setup/integration/compose/.env.template
+++ b/environment-setup/integration/compose/.env.template
@@ -14,3 +14,4 @@ ROBERT_JWT_PRIVATE_KEY=tochange
 ROBERT_JWT_PUBLIC_KEY=tochange
 ROBERT_JWT_TOKEN_DECLARE_PUBLIC_KID=tochange
 ROBERT_JWT_TOKEN_DECLARE_PRIVATE_KEY=tochange
+ROBERT_JWT_TOKEN_ANALYTICS_PRIVATE_KEY=tochange
diff --git a/environment-setup/integration/compose/docker-compose-robert-server.yaml b/environment-setup/integration/compose/docker-compose-robert-server.yaml
index 24caecc91b9973601c560430d7abe053ffb51c65..25522c2c9ef264587d9475c4fa15efd8761fd168 100644
--- a/environment-setup/integration/compose/docker-compose-robert-server.yaml
+++ b/environment-setup/integration/compose/docker-compose-robert-server.yaml
@@ -14,6 +14,7 @@ services:
       - ROBERT_JWT_PRIVATE_KEY=${ROBERT_JWT_PRIVATE_KEY}
       - ROBERT_JWT_TOKEN_DECLARE_PUBLIC_KID=${ROBERT_JWT_TOKEN_DECLARE_PUBLIC_KID}
       - ROBERT_JWT_TOKEN_DECLARE_PRIVATE_KEY=${ROBERT_JWT_TOKEN_DECLARE_PRIVATE_KEY}
+      - ROBERT_JWT_TOKEN_ANALYTICS_PRIVATE_KEY=${ROBERT_JWT_TOKEN_ANALYTICS_PRIVATE_KEY}
     ports:
       - "8086:8086"
     volumes:
diff --git a/environment-setup/integration/compose/robert-server-ws-rest-config/application.properties b/environment-setup/integration/compose/robert-server-ws-rest-config/application.properties
index c17fb7ed17fa23fb2e28f0851862cce20bc155be..771d704e8003cfa55a9a3c889e20eb3e6153a9c7 100644
--- a/environment-setup/integration/compose/robert-server-ws-rest-config/application.properties
+++ b/environment-setup/integration/compose/robert-server-ws-rest-config/application.properties
@@ -97,3 +97,6 @@ captcha.gateway.enabled=false
 robert.jwt.declare.public-kid=${ROBERT_JWT_TOKEN_DECLARE_PUBLIC_KID}
 robert.jwt.declare.private-key=${ROBERT_JWT_TOKEN_DECLARE_PRIVATE_KEY}
 
+robert.jwt.analytics.token.private-key=${ROBERT_JWT_TOKEN_ANALYTICS_PRIVATE_KEY}
+robert.jwt.analytics.token.lifetime=360
+
diff --git a/pom.xml b/pom.xml
index ecb15882841df2849d197901553c95ae82838806..e6f0172fa5ff1953439dd9e8a45f1cfed19304e3 100644
--- a/pom.xml
+++ b/pom.xml
@@ -1,27 +1,29 @@
 <?xml version="1.0" encoding="UTF-8"?>
-<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
-	<modelVersion>4.0.0</modelVersion>
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
 
-	<licenses>
-		<license>
-			<name>Mozilla Public License, Version 2.0</name>
-			<url>https://www.mozilla.org/en-US/MPL/2.0/</url>
-			<distribution>repo</distribution>
-		</license>
-	</licenses>
+    <licenses>
+        <license>
+            <name>Mozilla Public License, Version 2.0</name>
+            <url>https://www.mozilla.org/en-US/MPL/2.0/</url>
+            <distribution>repo</distribution>
+        </license>
+    </licenses>
+
+    <groupId>fr.gouv.stopc</groupId>
+    <artifactId>tac-server-root</artifactId>
+    <version>0.0.1-SNAPSHOT</version>
+    <name>tac-server-root</name>
+    <packaging>pom</packaging>
+    <description>Root Integration Project</description>
+
+    <modules>
+        <module>robert-server</module>
+        <module>analytics-server</module>
+    </modules>
 
-	<groupId>fr.gouv.stopc</groupId>
-	<artifactId>tac-server-root</artifactId>
-	<version>0.0.1-SNAPSHOT</version>
-	<name>tac-server-root</name>
-	<packaging>pom</packaging>
-	<description>Root Integration Project</description>
 
-	<modules>
-		<module>robert-server</module>
-	</modules>
-	
-	
     <profiles>
         <profile>
             <id>report</id>
@@ -46,7 +48,7 @@
                     </plugin>
                 </plugins>
             </build>
-             <reporting>
+            <reporting>
                 <plugins>
                     <plugin>
                         <groupId>org.apache.maven.plugins</groupId>
diff --git a/robert-server/pom.xml b/robert-server/pom.xml
index 7299ae92291ac6b2bb5fd9a9553fe7070821d6b4..bf2493dae6fd191ceeb53345ccb26048068fcaa8 100644
--- a/robert-server/pom.xml
+++ b/robert-server/pom.xml
@@ -19,7 +19,7 @@
 
 	<groupId>fr.gouv.stopc</groupId>
 	<artifactId>robert-server</artifactId>
-	<version>1.10.1</version>
+	<version>1.11.0</version>
 	<name>robert-server</name>
 	<packaging>pom</packaging>
 	<description>Projet principal</description>
diff --git a/robert-server/robert-api-spec/openapi.yaml b/robert-server/robert-api-spec/openapi.yaml
index 66550e22fb6dfb40dd9d9e7d01789b9accbcc102..6ab476293d97603552ac2c3245eb1652fecf7e0c 100644
--- a/robert-server/robert-api-spec/openapi.yaml
+++ b/robert-server/robert-api-spec/openapi.yaml
@@ -364,6 +364,10 @@ components:
           description: >-
             A JWT token used for exposition declaration to CNAM.
             Contains timestamps of the last contact date and notification date.
+        analyticsToken:
+          type: string
+          description: >-
+            A JWT token used for analytics.
         config:
           $ref: "#/components/schemas/ClientConfiguration"
       required:
diff --git a/robert-server/robert-crypto-grpc-server-messaging/pom.xml b/robert-server/robert-crypto-grpc-server-messaging/pom.xml
index 4de017f12332625f843c70271056a2a8977cf3ae..6e75f6998ce6fbbd7ada23c28113ce65b5d03d56 100644
--- a/robert-server/robert-crypto-grpc-server-messaging/pom.xml
+++ b/robert-server/robert-crypto-grpc-server-messaging/pom.xml
@@ -13,7 +13,7 @@
 	<parent>
 		<groupId>fr.gouv.stopc</groupId>
 		<artifactId>robert-server</artifactId>
-		<version>1.10.1</version>
+		<version>1.11.0</version>
 	</parent>
 
 	<artifactId>robert-crypto-grpc-server-messaging</artifactId>
diff --git a/robert-server/robert-crypto-grpc-server-storage/pom.xml b/robert-server/robert-crypto-grpc-server-storage/pom.xml
index dc4136d52189e5f1140251c502602b55d54f0764..65f1651a9ada4e44234b5554aef7dda3b867197c 100644
--- a/robert-server/robert-crypto-grpc-server-storage/pom.xml
+++ b/robert-server/robert-crypto-grpc-server-storage/pom.xml
@@ -13,7 +13,7 @@
 	<parent>
 		<groupId>fr.gouv.stopc</groupId>
 		<artifactId>robert-server</artifactId>
-		<version>1.10.1</version>
+		<version>1.11.0</version>
 	</parent>
 
 
diff --git a/robert-server/robert-crypto-grpc-server/pom.xml b/robert-server/robert-crypto-grpc-server/pom.xml
index 2cab543047ab750f227b4db80e43cce6f9ceea20..d452e4fa4eeb946cc4e09ce4480cf0c1b4a54982 100644
--- a/robert-server/robert-crypto-grpc-server/pom.xml
+++ b/robert-server/robert-crypto-grpc-server/pom.xml
@@ -13,7 +13,7 @@
 	<parent>
 		<groupId>fr.gouv.stopc</groupId>
 		<artifactId>robert-server</artifactId>
-		<version>1.10.1</version>
+		<version>1.11.0</version>
 	</parent>
 
 	<artifactId>robert-crypto-grpc-server</artifactId>
diff --git a/robert-server/robert-crypto-grpc-server/src/main/resources/logback.xml b/robert-server/robert-crypto-grpc-server/src/main/resources/logback.xml
index b9e29a2c7cc19ead9b53121aba02be9ce6b74193..5631419399a2e90c5f72d16642c40d2ac4378285 100644
--- a/robert-server/robert-crypto-grpc-server/src/main/resources/logback.xml
+++ b/robert-server/robert-crypto-grpc-server/src/main/resources/logback.xml
@@ -1,65 +1,65 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <configuration>
 
-	<property name="LOG_DIR"
-		value="${ROBERT_CRYPTO_SERVER_LOG_FILE_PATH:-/logs}" />
-	<property name="LOG_FILENAME"
-		value="${ROBERT_CRYPTO_SERVER_LOG_FILE_NAME:-robert-crypto-grpc-server}" />
-	<property name="ERROR_LOG_FILENAME"
-		value="${ROBERT_CRYPTO_SERVER_ERROR_LOG_FILE_NAME:-robert-crypto-grpc-server}.error" />
+    <property name="LOG_DIR"
+              value="${ROBERT_CRYPTO_SERVER_LOG_FILE_PATH:-/logs}" />
+    <property name="LOG_FILENAME"
+              value="${ROBERT_CRYPTO_SERVER_LOG_FILE_NAME:-robert-crypto-grpc-server}" />
+    <property name="ERROR_LOG_FILENAME"
+              value="${ROBERT_CRYPTO_SERVER_ERROR_LOG_FILE_NAME:-robert-crypto-grpc-server}.error" />
 
-	<appender name="RollingFile"
-		class="ch.qos.logback.core.rolling.RollingFileAppender">
-		<file>${LOG_DIR}/${LOG_FILENAME}.log</file>
-		<encoder
-			class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
-			<Pattern>%d %p %C{1.} [%t][%file:%line] %m%n</Pattern>
-		</encoder>
+    <appender name="RollingFile"
+              class="ch.qos.logback.core.rolling.RollingFileAppender">
+        <file>${LOG_DIR}/${LOG_FILENAME}.log</file>
+        <encoder
+                class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
+            <Pattern>%d %p %C{1.} [%t][%file:%line] %m%n</Pattern>
+        </encoder>
 
-		<rollingPolicy
-			class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
-			<!-- rollover daily and when the file reaches 10 MegaBytes -->
-			<fileNamePattern>${LOG_DIR}/${LOG_FILENAME}.%d{yyyy-MM-dd}.%i.log.gz
-			</fileNamePattern>
-			<maxFileSize>10MB</maxFileSize>
-		</rollingPolicy>
-	</appender>
+        <rollingPolicy
+                class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
+            <!-- rollover daily and when the file reaches 10 MegaBytes -->
+            <fileNamePattern>${LOG_DIR}/${LOG_FILENAME}.%d{yyyy-MM-dd}.%i.log.gz
+            </fileNamePattern>
+            <maxFileSize>10MB</maxFileSize>
+        </rollingPolicy>
+    </appender>
 
-	<appender name="RollingErrorFile"
-		class="ch.qos.logback.core.rolling.RollingFileAppender">
-		<file>${LOG_DIR}/${ERROR_LOG_FILENAME}.log</file>
-		<encoder
-			class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
-			<Pattern>%d %p %C{1.} [%t][%file:%line] %m%n</Pattern>
-		</encoder>
+    <appender name="RollingErrorFile"
+              class="ch.qos.logback.core.rolling.RollingFileAppender">
+        <file>${LOG_DIR}/${ERROR_LOG_FILENAME}.log</file>
+        <encoder
+                class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
+            <Pattern>%d %p %C{1.} [%t][%file:%line] %m%n</Pattern>
+        </encoder>
 
-		<rollingPolicy
-			class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
-			<!-- rollover daily and when the file reaches 10 MegaBytes -->
-			<fileNamePattern>${LOG_DIR}/${ERROR_LOG_FILENAME}.%d{yyyy-MM-dd}.%i.log.gz
-			</fileNamePattern>
-			<maxFileSize>10MB</maxFileSize>
-		</rollingPolicy>
-	</appender>
+        <rollingPolicy
+                class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
+            <!-- rollover daily and when the file reaches 10 MegaBytes -->
+            <fileNamePattern>${LOG_DIR}/${ERROR_LOG_FILENAME}.%d{yyyy-MM-dd}.%i.log.gz
+            </fileNamePattern>
+            <maxFileSize>10MB</maxFileSize>
+        </rollingPolicy>
+    </appender>
 
-	<!-- LOG everything at INFO level -->
-	<root level="info">
-		<appender-ref ref="RollingFile" />
-	</root>
+    <!-- LOG everything at INFO level -->
+    <root level="info">
+        <appender-ref ref="RollingFile" />
+    </root>
 
-	<!-- at TRACE level -->
-	<logger name="trace" level="trace" additivity="false">
-		<appender-ref ref="RollingFile" />
-	</logger>
+    <!-- at TRACE level -->
+    <logger name="trace" level="trace" additivity="false">
+        <appender-ref ref="RollingFile" />
+    </logger>
 
-	<!-- at WARN level -->
-	<logger name="warn" level="warn" additivity="false">
-		<appender-ref ref="RollingFile" />
-	</logger>
+    <!-- at WARN level -->
+    <logger name="warn" level="warn" additivity="false">
+        <appender-ref ref="RollingFile" />
+    </logger>
 
-	<!-- at ERROR level -->
-	<logger name="error" level="error" additivity="true">
-		<appender-ref ref="RollingErrorFile" />
-	</logger>
+    <!-- at ERROR level -->
+    <logger name="error" level="error" additivity="true">
+        <appender-ref ref="RollingErrorFile" />
+    </logger>
 
 </configuration>
diff --git a/robert-server/robert-server-batch/pom.xml b/robert-server/robert-server-batch/pom.xml
index 5eed7a978821eb7faaa01e27c736faac8f4c1843..0e3b3c8325032d2c9b4a6f2d89d8087286a67d98 100644
--- a/robert-server/robert-server-batch/pom.xml
+++ b/robert-server/robert-server-batch/pom.xml
@@ -13,7 +13,7 @@
 	<parent>
 		<groupId>fr.gouv.stopc</groupId>
 		<artifactId>robert-server</artifactId>
-		<version>1.10.1</version>
+		<version>1.11.0</version>
 	</parent>
 
 	<artifactId>robert-server-batch</artifactId>
diff --git a/robert-server/robert-server-common/pom.xml b/robert-server/robert-server-common/pom.xml
index bc14fad35f644af87301ff9f8f867f93f2254801..6852348b4811e0d933e9b20285e2b5527f7c3e8d 100644
--- a/robert-server/robert-server-common/pom.xml
+++ b/robert-server/robert-server-common/pom.xml
@@ -13,7 +13,7 @@
 	<parent>
 		<groupId>fr.gouv.stopc</groupId>
 		<artifactId>robert-server</artifactId>
-		<version>1.10.1</version>
+		<version>1.11.0</version>
 	</parent>
 
 	<artifactId>robert-server-common</artifactId>
diff --git a/robert-server/robert-server-crypto/pom.xml b/robert-server/robert-server-crypto/pom.xml
index 8737127ac99ebb0a68cf57d7f652a4ac1b8cc964..cc49821296b5e2ddfad0ed0f3ef0d8d88c9397d3 100644
--- a/robert-server/robert-server-crypto/pom.xml
+++ b/robert-server/robert-server-crypto/pom.xml
@@ -13,7 +13,7 @@
 	<parent>
 		<groupId>fr.gouv.stopc</groupId>
 		<artifactId>robert-server</artifactId>
-		<version>1.10.1</version>
+		<version>1.11.0</version>
 	</parent>
 
 	<artifactId>robert-server-crypto</artifactId>
diff --git a/robert-server/robert-server-database/pom.xml b/robert-server/robert-server-database/pom.xml
index 028956080b148e162f3b5e1871f1031231e8aae7..89e6af4027d7ddbe0a7c000fd992218ca2fafe65 100644
--- a/robert-server/robert-server-database/pom.xml
+++ b/robert-server/robert-server-database/pom.xml
@@ -13,7 +13,7 @@
 	<parent>
 		<groupId>fr.gouv.stopc</groupId>
 		<artifactId>robert-server</artifactId>
-		<version>1.10.1</version>
+		<version>1.11.0</version>
 	</parent>
 
 
diff --git a/robert-server/robert-server-dataset-injector/pom.xml b/robert-server/robert-server-dataset-injector/pom.xml
index d1b52e251f81b1dfa4ffc8b80986d950c2e1f70a..b86505713370bca37c90e20aa05c93572a0d8b45 100644
--- a/robert-server/robert-server-dataset-injector/pom.xml
+++ b/robert-server/robert-server-dataset-injector/pom.xml
@@ -13,7 +13,7 @@
 	<parent>
 		<groupId>fr.gouv.stopc</groupId>
 		<artifactId>robert-server</artifactId>
-		<version>1.10.1</version>
+		<version>1.11.0</version>
 	</parent>
 
 
diff --git a/robert-server/robert-server-ws-rest/pom.xml b/robert-server/robert-server-ws-rest/pom.xml
index bf09de62f35ac0fc69e42318c117c20bd5557afb..c6ea98e0f6ba3f08c3dd06770af9bb407e9e3db5 100644
--- a/robert-server/robert-server-ws-rest/pom.xml
+++ b/robert-server/robert-server-ws-rest/pom.xml
@@ -13,7 +13,7 @@
 	<parent>
 		<groupId>fr.gouv.stopc</groupId>
 		<artifactId>robert-server</artifactId>
-		<version>1.10.1</version>
+		<version>1.11.0</version>
 	</parent>
 
 	<artifactId>robert-server-ws-rest</artifactId>
diff --git a/robert-server/robert-server-ws-rest/src/main/docker/Dockerfile b/robert-server/robert-server-ws-rest/src/main/docker/Dockerfile
index 8b836a59430e4179c108ef843c1da20d5049a4a7..0e2eec0ce02dc16bba7bdb9e7b786f2a85d57efe 100644
--- a/robert-server/robert-server-ws-rest/src/main/docker/Dockerfile
+++ b/robert-server/robert-server-ws-rest/src/main/docker/Dockerfile
@@ -5,4 +5,5 @@ ADD ./src/main/resources/application.properties /work/config/application.propert
 ENV ROBERT_JWT_PRIVATE_KEY changeit
 ENV ROBERT_JWT_TOKEN_DECLARE_PRIVATE_KEY changeit
 ENV ROBERT_JWT_TOKEN_DECLARE_PUBLIC_KID changeit
-CMD ["java","-jar", "/app.jar", "--spring.config.location=file:/work/config/application.properties", "-Dspring-boot.run.arguments=--robert.jwt.privatekey=${ROBERT_JWT_PRIVATE_KEY},--robert.jwt.declare.public-kid=${ROBERT_JWT_TOKEN_DECLARE_PUBLIC_KID},--robert.jwt.declare.private-key=${ROBERT_JWT_TOKEN_DECLARE_PRIVATE_KEY}"]
+ENV ROBERT_JWT_TOKEN_ANALYTICS_PRIVATE_KEY changeit
+CMD ["java","-jar", "/app.jar", "--spring.config.location=file:/work/config/application.properties", "-Dspring-boot.run.arguments=--robert.jwt.privatekey=${ROBERT_JWT_PRIVATE_KEY},--robert.jwt.declare.public-kid=${ROBERT_JWT_TOKEN_DECLARE_PUBLIC_KID},--robert.jwt.declare.private-key=${ROBERT_JWT_TOKEN_DECLARE_PRIVATE_KEY},--robert.jwt.analytics.token.private-key=${ROBERT_JWT_TOKEN_ANALYTICS_PRIVATE_KEY}"]
diff --git a/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/config/WsServerConfiguration.java b/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/config/WsServerConfiguration.java
index c6ebf51bfd9eb2859504508dfb1c1c82c810e3f2..a3bd093a0bbe0ab9cb84a5c1e7d6040e1ff47001 100644
--- a/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/config/WsServerConfiguration.java
+++ b/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/config/WsServerConfiguration.java
@@ -35,4 +35,10 @@ public class WsServerConfiguration {
 
     @Value("${robert.jwt.declare.private-key}")
     private String declareTokenPrivateKey;
+
+    @Value("${robert.jwt.analytics.token.private-key}")
+    private String analyticsTokenPrivateKey;
+
+    @Value("${robert.jwt.analytics.token.lifetime}")
+    private Long analyticsTokenLifeTime;
 }
diff --git a/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/controller/ICaptchaController.java b/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/controller/ICaptchaController.java
index 7a7644d68fc54069916e8bfaadd3a69075c2299d..26a65886cf58001e1bfdb2745966e336583491d6 100644
--- a/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/controller/ICaptchaController.java
+++ b/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/controller/ICaptchaController.java
@@ -22,7 +22,8 @@ import fr.gouv.stopc.robertserver.ws.vo.CaptchaCreationVo;
 @RequestMapping(value = {"${controller.path.prefix}" + UriConstants.API_V2, 
 		"${controller.path.prefix}" + UriConstants.API_V3,
 		"${controller.path.prefix}" + UriConstants.API_V4,
-        "${controller.path.prefix}" + UriConstants.API_V5})
+        "${controller.path.prefix}" + UriConstants.API_V5,
+        "${controller.path.prefix}" + UriConstants.API_V6})
 public interface ICaptchaController {
 
     @PostMapping(value = UriConstants.CAPTCHA)
diff --git a/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/controller/IDeleteHistoryController.java b/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/controller/IDeleteHistoryController.java
index 1b37fb739b0f5cce32881544c2daa9277a2cbc51..27932facd21f320ccc1989e702505ec841f1dcd5 100644
--- a/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/controller/IDeleteHistoryController.java
+++ b/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/controller/IDeleteHistoryController.java
@@ -19,7 +19,7 @@ import fr.gouv.stopc.robertserver.ws.vo.DeleteHistoryRequestVo;
 @RestController
 @RequestMapping(value = {"${controller.path.prefix}" + UriConstants.API_V2,
         "${controller.path.prefix}" + UriConstants.API_V3, "${controller.path.prefix}" + UriConstants.API_V4,
-        "${controller.path.prefix}" + UriConstants.API_V5 })
+        "${controller.path.prefix}" + UriConstants.API_V5, "${controller.path.prefix}" + UriConstants.API_V6 })
 @Consumes(MediaType.APPLICATION_JSON_VALUE)
 @Produces(MediaType.APPLICATION_JSON_VALUE)
 public interface IDeleteHistoryController {
diff --git a/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/controller/IRegisterController.java b/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/controller/IRegisterController.java
index d2238f56d6ab707246117496f41d20ba7752f79e..740efbac09698c74e46d4f536f0c3cdc41a15495 100644
--- a/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/controller/IRegisterController.java
+++ b/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/controller/IRegisterController.java
@@ -4,7 +4,6 @@ import javax.validation.Valid;
 import javax.ws.rs.Consumes;
 import javax.ws.rs.Produces;
 
-import fr.gouv.stopc.robertserver.ws.vo.RegisterVo;
 import org.springframework.http.MediaType;
 import org.springframework.http.ResponseEntity;
 import org.springframework.web.bind.annotation.PostMapping;
@@ -15,10 +14,11 @@ import org.springframework.web.bind.annotation.RestController;
 import fr.gouv.stopc.robertserver.ws.dto.RegisterResponseDto;
 import fr.gouv.stopc.robertserver.ws.exception.RobertServerException;
 import fr.gouv.stopc.robertserver.ws.utils.UriConstants;
+import fr.gouv.stopc.robertserver.ws.vo.RegisterVo;
 
 @RestController
 @RequestMapping(value = {"${controller.path.prefix}" + UriConstants.API_V2, "${controller.path.prefix}" + UriConstants.API_V3,
-        "${controller.path.prefix}" + UriConstants.API_V4, "${controller.path.prefix}" + UriConstants.API_V5 })
+        "${controller.path.prefix}" + UriConstants.API_V4, "${controller.path.prefix}" + UriConstants.API_V5, "${controller.path.prefix}" + UriConstants.API_V6 })
 @Consumes(MediaType.APPLICATION_JSON_VALUE)
 @Produces(MediaType.APPLICATION_JSON_VALUE)
 public interface IRegisterController {
diff --git a/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/controller/IReportControllerV4.java b/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/controller/IReportControllerV4.java
index 77055f382e24dfd03f43802feb712ac0d975cb66..445e341524823a2b7d470a05cdc2674d999a70af 100644
--- a/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/controller/IReportControllerV4.java
+++ b/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/controller/IReportControllerV4.java
@@ -15,7 +15,7 @@ import fr.gouv.stopc.robertserver.ws.utils.UriConstants;
 import fr.gouv.stopc.robertserver.ws.vo.ReportBatchRequestVo;
 
 @RestController
-@RequestMapping(value = {"${controller.path.prefix}" + UriConstants.API_V4, "${controller.path.prefix}" + UriConstants.API_V5 },
+@RequestMapping(value = {"${controller.path.prefix}" + UriConstants.API_V4, "${controller.path.prefix}" + UriConstants.API_V5, "${controller.path.prefix}" + UriConstants.API_V6 },
 		consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
 public interface IReportControllerV4 {
 
diff --git a/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/controller/IStatusController.java b/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/controller/IStatusController.java
index 79e85236f8f06ead5d98df6f912903a5ff29574b..597595caf45031df38b37bd4f14c27fc349dae41 100644
--- a/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/controller/IStatusController.java
+++ b/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/controller/IStatusController.java
@@ -11,6 +11,7 @@ import org.springframework.web.bind.annotation.RestController;
 
 import fr.gouv.stopc.robertserver.ws.dto.StatusResponseDto;
 import fr.gouv.stopc.robertserver.ws.dto.StatusResponseDtoV1ToV4;
+import fr.gouv.stopc.robertserver.ws.dto.StatusResponseDtoV5;
 import fr.gouv.stopc.robertserver.ws.exception.RobertServerException;
 import fr.gouv.stopc.robertserver.ws.utils.UriConstants;
 import fr.gouv.stopc.robertserver.ws.vo.StatusVo;
@@ -25,6 +26,10 @@ public interface IStatusController {
             throws RobertServerException;
 
     @PostMapping(path = UriConstants.API_V5 + UriConstants.STATUS)
+    ResponseEntity<StatusResponseDtoV5> getStatusV5(@Valid @RequestBody(required = true) StatusVo statusVo)
+            throws RobertServerException;
+
+    @PostMapping(path = UriConstants.API_V6 + UriConstants.STATUS)
     ResponseEntity<StatusResponseDto> getStatus(@Valid @RequestBody(required = true) StatusVo statusVo)
             throws RobertServerException;
 
diff --git a/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/controller/IUnregisterController.java b/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/controller/IUnregisterController.java
index 45d9de6a0f5e11b25e12800684e5b81a86b7e00b..ec1b759ca659ec35aac1f1a9e168a1e7eedb48a3 100644
--- a/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/controller/IUnregisterController.java
+++ b/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/controller/IUnregisterController.java
@@ -18,7 +18,7 @@ import fr.gouv.stopc.robertserver.ws.vo.UnregisterRequestVo;
 @RestController
 @RequestMapping(value = {"${controller.path.prefix}" + UriConstants.API_V2,
         "${controller.path.prefix}" + UriConstants.API_V3, "${controller.path.prefix}" + UriConstants.API_V4,
-        "${controller.path.prefix}" + UriConstants.API_V5 })
+        "${controller.path.prefix}" + UriConstants.API_V5, "${controller.path.prefix}" + UriConstants.API_V6 })
 @Consumes(MediaType.APPLICATION_JSON_VALUE)
 @Produces(MediaType.APPLICATION_JSON_VALUE)
 public interface IUnregisterController {
diff --git a/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/controller/impl/StatusControllerImpl.java b/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/controller/impl/StatusControllerImpl.java
index 019312158dcb7b2a949cd7d566ed07c6dcf46602..b139e8779df8cf529f22bc4638ce04b44a11f0a2 100644
--- a/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/controller/impl/StatusControllerImpl.java
+++ b/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/controller/impl/StatusControllerImpl.java
@@ -27,6 +27,7 @@ import fr.gouv.stopc.robertserver.ws.dto.ClientConfigDto;
 import fr.gouv.stopc.robertserver.ws.dto.RiskLevel;
 import fr.gouv.stopc.robertserver.ws.dto.StatusResponseDto;
 import fr.gouv.stopc.robertserver.ws.dto.StatusResponseDtoV1ToV4;
+import fr.gouv.stopc.robertserver.ws.dto.StatusResponseDtoV5;
 import fr.gouv.stopc.robertserver.ws.dto.declaration.GenerateDeclarationTokenRequest;
 import fr.gouv.stopc.robertserver.ws.exception.RobertServerException;
 import fr.gouv.stopc.robertserver.ws.service.AuthRequestValidationService;
@@ -98,7 +99,32 @@ public class StatusControllerImpl implements IStatusController {
 	            .tuples(status.getTuples())
 	            .build());
 	}
-    
+
+	@Override
+	public ResponseEntity<StatusResponseDtoV5> getStatusV5(@Valid StatusVo statusVo) throws RobertServerException {
+		ResponseEntity<StatusResponseDto> statusResponse = this.getStatus(statusVo);
+		if (Objects.isNull(statusResponse) || Objects.isNull(statusResponse.getStatusCode())) {
+			log.error("The response of the status must not be null");
+			return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
+		}
+
+		if (statusResponse.getStatusCode().isError()) {
+			log.warn("Status HTTP response code is equal to : {}", statusResponse.getStatusCode());
+			return ResponseEntity.status(statusResponse.getStatusCode()).build();
+		}
+
+		StatusResponseDto status = statusResponse.getBody();
+		return ResponseEntity.ok(
+				StatusResponseDtoV5.builder()
+						.riskLevel(status.getRiskLevel())
+						.config(status.getConfig())
+						.tuples(status.getTuples())
+						.declarationToken(status.getDeclarationToken())
+						.lastContactDate(status.getLastContactDate())
+						.lastRiskScoringDate(status.getLastRiskScoringDate())
+						.build());
+	}
+
     @Override
     public ResponseEntity<StatusResponseDto> getStatus(StatusVo statusVo) {
 		AuthRequestValidationService.ValidationResult<GetIdFromStatusResponse> validationResult =
@@ -226,20 +252,20 @@ public class StatusControllerImpl implements IStatusController {
 		if (riskLevel != RiskLevel.NONE) {
 			record.setNotified(true);
 
-			// Include lastContactDate only if any
+			// Include lastContactDate only if any and if user is evaluated at risk
 			if (record.getLastContactTimestamp() > 0) {
 				statusResponse.setLastContactDate(Long.toString(record.getLastContactTimestamp()));
 			}
 
-			// Include lastRiskScoringDate only if any
+			// Include lastRiskScoringDate only if any and if user is evaluated at risk
 			if (record.getLatestRiskEpoch() > 0) {
 				long serviceTimeStart = serverConfigurationService.getServiceTimeStart();
 				statusResponse.setLastRiskScoringDate(Long.toString(TimeUtils.getNtpSeconds(record.getLatestRiskEpoch(), serviceTimeStart)));
 			}
 		}
 
-		//TODO: Test this in integration tests and update api spec
-		//Generate a declaration token if there is a RiskLevel > 0 and an associated lastContactDate
+
+		//Generate declaration token (for CNAM) and atRisk token (for Analytics)
 		if (!RiskLevel.NONE.equals(riskLevel) && record.getLastContactTimestamp() > 0) {
 			long lastStatusRequestTimestamp = TimeUtils.getNtpSeconds(
 					record.getLastStatusRequestEpoch(),
@@ -253,8 +279,14 @@ public class StatusControllerImpl implements IStatusController {
 					.build();
 			String declarationToken = declarationService.generateDeclarationToken(request).orElse(null);
 			statusResponse.setDeclarationToken(declarationToken);
+			log.debug("Declaration token generated : {}", declarationToken);
+
 		}
 
+		String analyticsToken = declarationService.generateAnalyticsToken().orElse(null);
+		statusResponse.setAnalyticsToken(analyticsToken);
+		log.debug("analytics token generated : {}", analyticsToken);
+
 		// Save changes to the record
 		this.registrationService.saveRegistration(record);
 
diff --git a/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/dto/StatusResponseDto.java b/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/dto/StatusResponseDto.java
index 5a4ff1ed5542b1712c9b8a9969be0ebc5724e1bd..585a01366910b12d649a47b7942f8b345d92e7d2 100644
--- a/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/dto/StatusResponseDto.java
+++ b/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/dto/StatusResponseDto.java
@@ -28,4 +28,7 @@ public class StatusResponseDto {
     private String lastRiskScoringDate;
 
     private String declarationToken;
+
+    private String analyticsToken;
+
 }
diff --git a/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/dto/StatusResponseDtoV5.java b/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/dto/StatusResponseDtoV5.java
new file mode 100644
index 0000000000000000000000000000000000000000..4395660b45ce84e6eb8289ca4348bba70f3ccd40
--- /dev/null
+++ b/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/dto/StatusResponseDtoV5.java
@@ -0,0 +1,37 @@
+package fr.gouv.stopc.robertserver.ws.dto;
+
+import java.util.List;
+
+import javax.validation.constraints.NotNull;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import lombok.Singular;
+
+@AllArgsConstructor
+@NoArgsConstructor
+@Data
+@Builder
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class StatusResponseDtoV5 {
+    @NotNull
+    private RiskLevel riskLevel;
+
+    @NotNull
+    private String tuples;
+
+    @Singular("config")
+    private List<ClientConfigDto> config;
+
+    private String message;
+
+    private String lastContactDate;
+
+    private String lastRiskScoringDate;
+
+    private String declarationToken;
+
+}
diff --git a/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/service/DeclarationService.java b/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/service/DeclarationService.java
index d3e070edb07b891d93cf9ec47ff59d128aa73844..2de359fde329037a74e03b18ea89975ab1b85b7f 100644
--- a/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/service/DeclarationService.java
+++ b/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/service/DeclarationService.java
@@ -6,5 +6,18 @@ import java.util.Optional;
 
 public interface DeclarationService {
 
+    /**
+     * Generate a declaration token (used for CNAM)
+     * The custom JTI ensure that two tokens can be consumed with exactly the same exposition context
+     * @param request business and technical infos
+     * @return the token
+     */
     Optional<String> generateDeclarationToken(GenerateDeclarationTokenRequest request);
+
+    /**
+     * Generate a simple token (used for Analytics)
+     * @return the token
+     */
+    Optional<String> generateAnalyticsToken();
+
 }
diff --git a/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/service/impl/DeclarationServiceImpl.java b/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/service/impl/DeclarationServiceImpl.java
index a562a208ad4b8a6e9f588e2b807a7fdb443c1aca..20f6bb47169ee2a5ba08998ea4bc9f489d00c1b0 100644
--- a/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/service/impl/DeclarationServiceImpl.java
+++ b/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/service/impl/DeclarationServiceImpl.java
@@ -1,5 +1,19 @@
 package fr.gouv.stopc.robertserver.ws.service.impl;
 
+import java.security.KeyFactory;
+import java.security.KeyPair;
+import java.security.NoSuchAlgorithmException;
+import java.security.PrivateKey;
+import java.security.spec.InvalidKeySpecException;
+import java.security.spec.PKCS8EncodedKeySpec;
+import java.time.ZonedDateTime;
+import java.time.temporal.ChronoUnit;
+import java.util.Date;
+import java.util.Optional;
+import java.util.UUID;
+
+import org.springframework.stereotype.Service;
+
 import fr.gouv.stopc.robertserver.ws.config.WsServerConfiguration;
 import fr.gouv.stopc.robertserver.ws.dto.declaration.GenerateDeclarationTokenRequest;
 import fr.gouv.stopc.robertserver.ws.service.DeclarationService;
@@ -9,23 +23,11 @@ import io.jsonwebtoken.io.Decoders;
 import io.jsonwebtoken.security.Keys;
 import lombok.extern.slf4j.Slf4j;
 import org.apache.commons.codec.digest.DigestUtils;
-import org.springframework.stereotype.Service;
-
-import java.security.KeyFactory;
-import java.security.KeyPair;
-import java.security.NoSuchAlgorithmException;
-import java.security.PrivateKey;
-import java.security.spec.InvalidKeySpecException;
-import java.security.spec.PKCS8EncodedKeySpec;
-import java.util.Date;
-import java.util.Optional;
 
 @Service
 @Slf4j
 public class DeclarationServiceImpl implements DeclarationService {
 
-    //TODO: Test this class
-
     public static final SignatureAlgorithm SIGNATURE_ALGORITHM = SignatureAlgorithm.RS256;
 
     private final WsServerConfiguration configuration;
@@ -64,6 +66,34 @@ public class DeclarationServiceImpl implements DeclarationService {
         }
     }
 
+    @Override
+    public Optional<String> generateAnalyticsToken() {
+
+        log.debug("Generating simple analytics token");
+
+        try {
+            Date issuedAt = Date.from(ZonedDateTime.now().toInstant());
+            String jti = UUID.randomUUID().toString();
+            Date expiredAt = Date.from(
+                    issuedAt.toInstant()
+                            .plus(configuration.getAnalyticsTokenLifeTime(), ChronoUnit.MINUTES));
+
+            return Optional.of(
+                    Jwts.builder()
+                            .setHeaderParam("type", "JWT")
+                            .setId(jti)
+                            .setIssuedAt(issuedAt)
+                            .setExpiration(expiredAt)
+                            .setIssuer("robert-server")
+                            .signWith(getAnalyticsTokenPrivateKey(), SIGNATURE_ALGORITHM)
+                            .compact());
+
+        } catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
+            log.error("Creation of analytics JWT token failed ", e);
+            return Optional.empty();
+        }
+    }
+
     private PrivateKey getDeclarePrivateKey() throws NoSuchAlgorithmException, InvalidKeySpecException {
 
         if (configuration.getJwtUseTransientKey()) {
@@ -79,4 +109,19 @@ public class DeclarationServiceImpl implements DeclarationService {
 
     }
 
+    private PrivateKey getAnalyticsTokenPrivateKey() throws NoSuchAlgorithmException, InvalidKeySpecException {
+
+        if (configuration.getJwtUseTransientKey()) {
+            // In test mode, we generate a transient key
+            KeyPair keyPair = Keys.keyPairFor(SIGNATURE_ALGORITHM);
+            return keyPair.getPrivate();
+        } else {
+            byte[] encoded = Decoders.BASE64.decode(configuration.getAnalyticsTokenPrivateKey());
+            KeyFactory keyFactory = KeyFactory.getInstance(SIGNATURE_ALGORITHM.getFamilyName());
+            PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(encoded);
+            return keyFactory.generatePrivate(keySpec);
+        }
+
+    }
+
 }
diff --git a/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/utils/UriConstants.java b/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/utils/UriConstants.java
index 35a2ce503ab4d9d402d27142706c0265e7ba2511..18137f7caa077f027b3cfb89efcf2a3dfab1d09c 100644
--- a/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/utils/UriConstants.java
+++ b/robert-server/robert-server-ws-rest/src/main/java/fr/gouv/stopc/robertserver/ws/utils/UriConstants.java
@@ -29,4 +29,6 @@ public final class UriConstants {
 
     public static final String API_V5 = "/v5";
 
+    public static final String API_V6 = "/v6";
+
 }
diff --git a/robert-server/robert-server-ws-rest/src/main/resources/application-dev.properties b/robert-server/robert-server-ws-rest/src/main/resources/application-dev.properties
index 210fd6de2385dd7aaf670caed0c239929f016b04..59e0ea46ea9d7e068d0a02a7d51016dfa68c9300 100644
--- a/robert-server/robert-server-ws-rest/src/main/resources/application-dev.properties
+++ b/robert-server/robert-server-ws-rest/src/main/resources/application-dev.properties
@@ -78,3 +78,7 @@ robert.epoch-bundle-duration-in-days=4
 #JWT declare token
 robert.jwt.declare.public-kid=${ROBERT_JWT_TOKEN_DECLARE_PUBLIC_KID:public-kid}
 robert.jwt.declare.private-key=${ROBERT_JWT_TOKEN_DECLARE_PRIVATE_KEY:declare-private-key}
+
+#JWT analytics
+robert.jwt.analytics.token.private-key=${ROBERT_JWT_TOKEN_ANALYTICS_PRIVATE_KEY}
+robert.jwt.analytics.token.lifetime=${ROBERT_JWT_TOKEN_ANALYTICS_LIFETIME:360}
diff --git a/robert-server/robert-server-ws-rest/src/main/resources/application.properties b/robert-server/robert-server-ws-rest/src/main/resources/application.properties
index f603764b6a2615c068bebfb2509d6734a6e2a881..012b573cabe718a41d931f5bd3ecaee0634ede55 100644
--- a/robert-server/robert-server-ws-rest/src/main/resources/application.properties
+++ b/robert-server/robert-server-ws-rest/src/main/resources/application.properties
@@ -77,6 +77,9 @@ robert.jwt.privatekey=${ROBERT_JWT_PRIVATE_KEY}
 captcha.gateway.enabled=true
 
 #JWT declare token
-# TODO move both to Vault and Consul
 robert.jwt.declare.public-kid=${ROBERT_JWT_TOKEN_DECLARE_PUBLIC_KID}
-robert.jwt.declare.private-key=${ROBERT_JWT_TOKEN_DECLARE_PRIVATE_KEY}
\ No newline at end of file
+robert.jwt.declare.private-key=${ROBERT_JWT_TOKEN_DECLARE_PRIVATE_KEY}
+
+#JWT analytics
+robert.jwt.analytics.token.private-key=${ROBERT_JWT_TOKEN_ANALYTICS_PRIVATE_KEY}
+robert.jwt.analytics.token.lifetime=${ROBERT_JWT_TOKEN_ANALYTICS_LIFETIME:360}
\ No newline at end of file
diff --git a/robert-server/robert-server-ws-rest/src/test/java/test/fr/gouv/stopc/robertserver/ws/DeleteHistoryControllerWsRestTest.java b/robert-server/robert-server-ws-rest/src/test/java/test/fr/gouv/stopc/robertserver/ws/DeleteHistoryControllerWsRestTest.java
index 6c65483b83f13f5c638494c753ec164a6bd92b2a..20ec7bb0c82d720375a253b9ed0e86def357c317 100644
--- a/robert-server/robert-server-ws-rest/src/test/java/test/fr/gouv/stopc/robertserver/ws/DeleteHistoryControllerWsRestTest.java
+++ b/robert-server/robert-server-ws-rest/src/test/java/test/fr/gouv/stopc/robertserver/ws/DeleteHistoryControllerWsRestTest.java
@@ -1,5 +1,37 @@
 package test.fr.gouv.stopc.robertserver.ws;
 
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+import java.net.URI;
+import java.security.Key;
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+
+import javax.crypto.KeyGenerator;
+import javax.inject.Inject;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.boot.test.web.client.TestRestTemplate;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.test.context.TestPropertySource;
+import org.springframework.web.util.UriComponentsBuilder;
+
 import com.google.protobuf.ByteString;
 import fr.gouv.stopc.robert.crypto.grpc.server.client.service.ICryptoServerGrpcClient;
 import fr.gouv.stopc.robert.crypto.grpc.server.messaging.GetIdFromAuthResponse;
@@ -23,27 +55,6 @@ import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.mockito.ArgumentCaptor;
 import org.mockito.ArgumentMatchers;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.beans.factory.annotation.Value;
-import org.springframework.boot.test.context.SpringBootTest;
-import org.springframework.boot.test.mock.mockito.MockBean;
-import org.springframework.boot.test.web.client.TestRestTemplate;
-import org.springframework.http.*;
-import org.springframework.test.context.TestPropertySource;
-import org.springframework.web.util.UriComponentsBuilder;
-
-import javax.crypto.KeyGenerator;
-import javax.inject.Inject;
-import java.net.URI;
-import java.security.Key;
-import java.security.NoSuchAlgorithmException;
-import java.security.SecureRandom;
-import java.util.*;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertTrue;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.Mockito.*;
 
 @SpringBootTest(classes = {
         RobertServerWsRestApplication.class }, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@@ -62,6 +73,9 @@ public class DeleteHistoryControllerWsRestTest {
     private String pathPrefixV4;
 
     @Value("${controller.path.prefix}" + UriConstants.API_V5)
+    private String pathPrefixV5;
+
+    @Value("${controller.path.prefix}" + UriConstants.API_V6)
     private String pathPrefix;
 
     @Inject
@@ -174,6 +188,12 @@ public class DeleteHistoryControllerWsRestTest {
         deleteHistoryWithExposedEpochsSucceeds(UriComponentsBuilder.fromUriString(this.pathPrefixV4).path(UriConstants.DELETE_HISTORY).build().encode().toUri());
     }
 
+    /** Test the access for API V5, should not be used since API V6 */
+    @Test
+    public void testAccessV5() {
+        deleteHistoryWithExposedEpochsSucceeds(UriComponentsBuilder.fromUriString(this.pathPrefixV5).path(UriConstants.DELETE_HISTORY).build().encode().toUri());
+    }
+
     /** {@link #deleteHistoryWithExposedEpochsSucceeds(URI)} and shortcut to test for API V2 exposure */
     @Test
     public void testDeleteHistoryWithExposedEpochsSucceedsV3() {
diff --git a/robert-server/robert-server-ws-rest/src/test/java/test/fr/gouv/stopc/robertserver/ws/RegisterControllerWsRestTest.java b/robert-server/robert-server-ws-rest/src/test/java/test/fr/gouv/stopc/robertserver/ws/RegisterControllerWsRestTest.java
index e6e317822d4de6c09898f2dd5cd886bb6ce7a3be..ea0e843e171916836e97dec585d67656e119032e 100644
--- a/robert-server/robert-server-ws-rest/src/test/java/test/fr/gouv/stopc/robertserver/ws/RegisterControllerWsRestTest.java
+++ b/robert-server/robert-server-ws-rest/src/test/java/test/fr/gouv/stopc/robertserver/ws/RegisterControllerWsRestTest.java
@@ -1,5 +1,28 @@
 package test.fr.gouv.stopc.robertserver.ws;
 
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+import java.net.URI;
+import java.util.Optional;
+
+import javax.inject.Inject;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.boot.test.web.client.TestRestTemplate;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.test.context.TestPropertySource;
+import org.springframework.web.util.UriComponentsBuilder;
+
 import com.google.protobuf.ByteString;
 import fr.gouv.stopc.robert.crypto.grpc.server.client.service.ICryptoServerGrpcClient;
 import fr.gouv.stopc.robert.crypto.grpc.server.messaging.CreateRegistrationResponse;
@@ -20,22 +43,6 @@ import org.bson.internal.Base64;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.mockito.ArgumentMatchers;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.beans.factory.annotation.Value;
-import org.springframework.boot.test.context.SpringBootTest;
-import org.springframework.boot.test.mock.mockito.MockBean;
-import org.springframework.boot.test.web.client.TestRestTemplate;
-import org.springframework.http.*;
-import org.springframework.test.context.TestPropertySource;
-import org.springframework.web.util.UriComponentsBuilder;
-
-import javax.inject.Inject;
-import java.net.URI;
-import java.util.Optional;
-
-import static org.junit.jupiter.api.Assertions.*;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.Mockito.*;
 
 @SpringBootTest(classes = {
         RobertServerWsRestApplication.class }, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@@ -53,6 +60,9 @@ public class RegisterControllerWsRestTest {
     private String pathPrefixV4;
 
     @Value("${controller.path.prefix}" + UriConstants.API_V5)
+    private String pathPrefixV5;
+
+    @Value("${controller.path.prefix}" + UriConstants.API_V6)
     private String pathPrefix;
 
     @Inject
@@ -275,7 +285,13 @@ public class RegisterControllerWsRestTest {
         testRegisterSucceeds(UriComponentsBuilder.fromUriString(this.pathPrefixV4).path(UriConstants.REGISTER).build().toUri()
                 .toString());
     }
-    
+
+    @Test
+    public void testSuccessV5() {
+        testRegisterSucceeds(UriComponentsBuilder.fromUriString(this.pathPrefixV5).path(UriConstants.REGISTER).build().toUri()
+                .toString());
+    }
+
     @Test
     public void testSuccess() {
         testRegisterSucceeds(this.targetUrl.toString());
diff --git a/robert-server/robert-server-ws-rest/src/test/java/test/fr/gouv/stopc/robertserver/ws/ReportControllerWsRestTest.java b/robert-server/robert-server-ws-rest/src/test/java/test/fr/gouv/stopc/robertserver/ws/ReportControllerWsRestTest.java
index 9703b2c29ba057d5e59717eb80554c68d4709807..7a3db7535268a571aaa8a93a3ef4b2d6c0204201 100644
--- a/robert-server/robert-server-ws-rest/src/test/java/test/fr/gouv/stopc/robertserver/ws/ReportControllerWsRestTest.java
+++ b/robert-server/robert-server-ws-rest/src/test/java/test/fr/gouv/stopc/robertserver/ws/ReportControllerWsRestTest.java
@@ -1,5 +1,32 @@
 package test.fr.gouv.stopc.robertserver.ws;
 
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.equalTo;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+import java.net.URI;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.boot.test.web.client.TestRestTemplate;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.test.context.TestPropertySource;
+import org.springframework.web.util.UriComponentsBuilder;
+
 import fr.gouv.stopc.robertserver.ws.RobertServerWsRestApplication;
 import fr.gouv.stopc.robertserver.ws.config.RobertServerWsConfiguration;
 import fr.gouv.stopc.robertserver.ws.dto.ReportBatchResponseDto;
@@ -18,27 +45,6 @@ import fr.gouv.stopc.robertserver.ws.vo.ReportBatchRequestVo;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.mockito.Mock;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.beans.factory.annotation.Value;
-import org.springframework.boot.test.context.SpringBootTest;
-import org.springframework.boot.test.mock.mockito.MockBean;
-import org.springframework.boot.test.web.client.TestRestTemplate;
-import org.springframework.http.*;
-import org.springframework.test.context.TestPropertySource;
-import org.springframework.web.util.UriComponentsBuilder;
-
-import java.net.URI;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.List;
-import java.util.Optional;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.hamcrest.MatcherAssert.assertThat;
-import static org.hamcrest.Matchers.equalTo;
-import static org.junit.jupiter.api.Assertions.*;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.Mockito.*;
 
 @SpringBootTest(classes = { RobertServerWsRestApplication.class }, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
 @TestPropertySource("classpath:application.properties")
@@ -70,6 +76,9 @@ public class ReportControllerWsRestTest {
     private String pathPrefixV4;
 
     @Value("${controller.path.prefix}" + UriConstants.API_V5)
+    private String pathPrefixV5;
+
+    @Value("${controller.path.prefix}" + UriConstants.API_V6)
     private String pathPrefix;
 
 	@Value("${robert.server.disable-check-token}")
@@ -256,7 +265,13 @@ public class ReportControllerWsRestTest {
     public void testAccessV4WithV3Dto() {
         reportContactHistoryV2OrV3Succeeds(UriComponentsBuilder.fromUriString(this.pathPrefixV4).path(UriConstants.REPORT).build().encode().toUri());
     }
-    
+
+    /** Test the access for API V5 with old DTO, should not be used since API V6. */
+    @Test
+    public void testAccessV5WithV3Dto() {
+        reportContactHistoryV2OrV3Succeeds(UriComponentsBuilder.fromUriString(this.pathPrefixV5).path(UriConstants.REPORT).build().encode().toUri());
+    }
+
     /** {@link #reportContactHistoryV2OrV3Succeeds(URI)} Test the access for API V5 with old DTO */
     @Test
     public void testReportContactHistorySucceedsWithV3Dto() {
@@ -293,8 +308,14 @@ public class ReportControllerWsRestTest {
     public void testAccessV4() {
         reportContactHistorySucceeds(UriComponentsBuilder.fromUriString(this.pathPrefixV4).path(UriConstants.REPORT).build().encode().toUri());
     }
-    
-    /** {@link #reportContactHistorySucceeds(URI)} Test the access for API V5 */
+
+    /** Test the access for API V5 with old DTO, should not be used since API V6. */
+    @Test
+    public void testAccessV5() {
+        reportContactHistorySucceeds(UriComponentsBuilder.fromUriString(this.pathPrefixV5).path(UriConstants.REPORT).build().encode().toUri());
+    }
+
+    /** {@link #reportContactHistorySucceeds(URI)} Test the access for API V6 */
     @Test
     public void testReportContactHistorySucceeds() {
         reportContactHistorySucceeds(this.targetUrl);
diff --git a/robert-server/robert-server-ws-rest/src/test/java/test/fr/gouv/stopc/robertserver/ws/StatusControllerWsRestTest.java b/robert-server/robert-server-ws-rest/src/test/java/test/fr/gouv/stopc/robertserver/ws/StatusControllerWsRestTest.java
index 6a4335683d671abbb7d0128eeb78eaa0a20b88e4..54ba5042afbb8c91f0a132cc1271c05e47d7a593 100644
--- a/robert-server/robert-server-ws-rest/src/test/java/test/fr/gouv/stopc/robertserver/ws/StatusControllerWsRestTest.java
+++ b/robert-server/robert-server-ws-rest/src/test/java/test/fr/gouv/stopc/robertserver/ws/StatusControllerWsRestTest.java
@@ -1,5 +1,38 @@
 package test.fr.gouv.stopc.robertserver.ws;
 
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+import java.net.URI;
+import java.security.Key;
+import java.security.KeyPair;
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+
+import javax.crypto.KeyGenerator;
+import javax.inject.Inject;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.boot.test.web.client.TestRestTemplate;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.test.context.TestPropertySource;
+import org.springframework.web.util.UriComponentsBuilder;
+
 import com.google.protobuf.ByteString;
 import fr.gouv.stopc.robert.crypto.grpc.server.client.service.ICryptoServerGrpcClient;
 import fr.gouv.stopc.robert.crypto.grpc.server.messaging.GetIdFromStatusResponse;
@@ -19,6 +52,7 @@ import fr.gouv.stopc.robertserver.ws.config.WsServerConfiguration;
 import fr.gouv.stopc.robertserver.ws.dto.RiskLevel;
 import fr.gouv.stopc.robertserver.ws.dto.StatusResponseDto;
 import fr.gouv.stopc.robertserver.ws.dto.StatusResponseDtoV1ToV4;
+import fr.gouv.stopc.robertserver.ws.dto.StatusResponseDtoV5;
 import fr.gouv.stopc.robertserver.ws.service.IRestApiService;
 import fr.gouv.stopc.robertserver.ws.utils.PropertyLoader;
 import fr.gouv.stopc.robertserver.ws.utils.UriConstants;
@@ -34,28 +68,6 @@ import org.bson.internal.Base64;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.mockito.ArgumentMatchers;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.beans.factory.annotation.Value;
-import org.springframework.boot.test.context.SpringBootTest;
-import org.springframework.boot.test.mock.mockito.MockBean;
-import org.springframework.boot.test.web.client.TestRestTemplate;
-import org.springframework.http.*;
-import org.springframework.test.context.TestPropertySource;
-import org.springframework.web.util.UriComponentsBuilder;
-
-import javax.crypto.KeyGenerator;
-import javax.inject.Inject;
-import java.net.URI;
-import java.security.Key;
-import java.security.KeyPair;
-import java.security.NoSuchAlgorithmException;
-import java.security.SecureRandom;
-import java.util.*;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.junit.jupiter.api.Assertions.*;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.Mockito.*;
 
 @SpringBootTest(classes = {
         RobertServerWsRestApplication.class }, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@@ -76,6 +88,9 @@ public class StatusControllerWsRestTest {
     private String pathPrefixV4;
 
     @Value("${controller.path.prefix}" + UriConstants.API_V5)
+    private String pathPrefixV5;
+
+    @Value("${controller.path.prefix}" + UriConstants.API_V6)
     private String pathPrefix;
 
     @Value("${robert.server.status-request-minimum-epoch-gap}")
@@ -201,6 +216,8 @@ public class StatusControllerWsRestTest {
         System.arraycopy(idA, 0, decryptedEbid, 3, 5);
         System.arraycopy(ByteUtils.intToBytes(oldEpoch), 1, decryptedEbid, 0, 3);
 
+        when(this.wsServerConfiguration.getJwtUseTransientKey()).thenReturn(true);
+
         doReturn(Optional.of(reg)).when(this.registrationService).findById(idA);
 
         doReturn(Optional.of(GetIdFromStatusResponse.newBuilder()
@@ -629,9 +646,15 @@ public class StatusControllerWsRestTest {
         statusRequestAtRiskSucceedsV1ToV4(UriComponentsBuilder.fromUriString(this.pathPrefixV4).path(UriConstants.STATUS).build().encode().toUri());
     }
 
-    /** {@link #statusRequestAtRiskSucceeds(URI)} and shortcut to test for API V5 exposure */
+    /** Test the access for API V5, should not be used since API V6 */
+    @Test
+    public void testAccessV5() {
+        statusRequestAtRiskSucceedsV5(UriComponentsBuilder.fromUriString(this.pathPrefixV5).path(UriConstants.STATUS).build().encode().toUri());
+    }
+
+    /** {@link #statusRequestAtRiskSucceeds(URI)} and shortcut to test for API V6 exposure */
     @Test
-    public void testStatusRequestAtRiskSucceedsV5() {
+    public void testStatusRequestAtRiskSucceedsV6() {
         statusRequestAtRiskSucceeds(this.targetUrl);
     }
 
@@ -695,7 +718,34 @@ public class StatusControllerWsRestTest {
         verify(this.registrationService, times(2)).saveRegistration(reg);
         verify(this.restApiService, never()).registerPushNotif(any(PushInfoVo.class));
     }
-    
+
+    protected void statusRequestAtRiskSucceedsV5(URI targetUrl) {
+        // Given
+        byte[] idA = this.generateKey(5);
+        Registration reg = this.statusRequestAtRiskSucceedsSetUp(targetUrl, idA);
+
+        when(this.wsServerConfiguration.getDeclareTokenKid()).thenReturn("kid");
+        when(this.wsServerConfiguration.getJwtUseTransientKey()).thenReturn(true);
+
+        // When
+        ResponseEntity<StatusResponseDtoV5> response = this.restTemplate.exchange(targetUrl.toString(),
+                HttpMethod.POST, this.requestEntity, StatusResponseDtoV5.class);
+
+        // Then
+        assertEquals(HttpStatus.OK, response.getStatusCode());
+        assertEquals(RiskLevel.HIGH, response.getBody().getRiskLevel());
+        assertNotNull(response.getBody().getTuples());
+        assertEquals(Long.toString(reg.getLastContactTimestamp()), response.getBody().getLastContactDate());
+        assertNotNull(response.getBody().getLastRiskScoringDate());
+        assertTrue(Long.parseLong(response.getBody().getLastRiskScoringDate()) > 0);
+        assertTrue(reg.isNotified());
+        assertTrue(currentEpoch - 3 < reg.getLastStatusRequestEpoch());
+        verify(this.registrationService, times(2)).findById(idA);
+        verify(this.registrationService, times(2)).saveRegistration(reg);
+        verify(this.restApiService, never()).registerPushNotif(any(PushInfoVo.class));
+    }
+
+
     protected void statusRequestAtRiskSucceeds(URI targetUrl) {
         // Given
         byte[] idA = this.generateKey(5);
@@ -720,6 +770,7 @@ public class StatusControllerWsRestTest {
         verify(this.registrationService, times(2)).findById(idA);
         verify(this.registrationService, times(2)).saveRegistration(reg);
         verify(this.restApiService, never()).registerPushNotif(any(PushInfoVo.class));
+        assertNotNull(response.getBody().getAnalyticsToken());
     }
 
     @Test
@@ -749,6 +800,8 @@ public class StatusControllerWsRestTest {
         System.arraycopy(idA, 0, decryptedEbid, 3, 5);
         System.arraycopy(ByteUtils.intToBytes(currentEpoch), 1, decryptedEbid, 0, 3);
 
+        when(this.wsServerConfiguration.getJwtUseTransientKey()).thenReturn(true);
+
         doReturn(Optional.of(reg)).when(this.registrationService).findById(idA);
 
         doReturn(Optional.of(GetIdFromStatusResponse.newBuilder()
@@ -782,6 +835,8 @@ public class StatusControllerWsRestTest {
         byte[] idA = this.generateKey(5);
         byte[] kA = this.generateKA();
 
+        when(this.wsServerConfiguration.getJwtUseTransientKey()).thenReturn(true);
+
         List<EpochExposition> epochExpositions = new ArrayList<>();
 
         // Before latest notification
@@ -892,6 +947,8 @@ public class StatusControllerWsRestTest {
         System.arraycopy(idA, 0, decryptedEbid, 3, 5);
         System.arraycopy(ByteUtils.intToBytes(currentEpoch), 1, decryptedEbid, 0, 3);
 
+        when(this.wsServerConfiguration.getJwtUseTransientKey()).thenReturn(true);
+
         doReturn(Optional.of(reg)).when(this.registrationService).findById(idA);
 
         doReturn(Optional.of(GetIdFromStatusResponse.newBuilder()
@@ -1075,6 +1132,8 @@ public class StatusControllerWsRestTest {
         System.arraycopy(idA, 0, decryptedEbid, 3, 5);
         System.arraycopy(ByteUtils.intToBytes(currentEpoch), 1, decryptedEbid, 0, 3);
 
+        when(this.wsServerConfiguration.getJwtUseTransientKey()).thenReturn(true);
+
         doReturn(Optional.of(reg)).when(this.registrationService).findById(idA);
 
         doReturn(Optional.of(GetIdFromStatusResponse.newBuilder()
@@ -1136,6 +1195,8 @@ public class StatusControllerWsRestTest {
         System.arraycopy(idA, 0, decryptedEbid, 3, 5);
         System.arraycopy(ByteUtils.intToBytes(currentEpoch), 1, decryptedEbid, 0, 3);
 
+        when(this.wsServerConfiguration.getJwtUseTransientKey()).thenReturn(true);
+
         doReturn(Optional.of(reg)).when(this.registrationService).findById(idA);
 
         doReturn(Optional.of(GetIdFromStatusResponse.newBuilder()
@@ -1207,6 +1268,8 @@ public class StatusControllerWsRestTest {
         System.arraycopy(idA, 0, decryptedEbid, 3, 5);
         System.arraycopy(ByteUtils.intToBytes(currentEpoch), 1, decryptedEbid, 0, 3);
 
+        when(this.wsServerConfiguration.getJwtUseTransientKey()).thenReturn(true);
+
         doReturn(Optional.of(reg)).when(this.registrationService).findById(idA);
 
         doReturn(Optional.of(GetIdFromStatusResponse.newBuilder()
@@ -1319,6 +1382,8 @@ public class StatusControllerWsRestTest {
         System.arraycopy(idA, 0, decryptedEbid, 3, 5);
         System.arraycopy(ByteUtils.intToBytes(currentEpoch), 1, decryptedEbid, 0, 3);
 
+        when(this.wsServerConfiguration.getJwtUseTransientKey()).thenReturn(true);
+
         doReturn(Optional.of(reg)).when(this.registrationService).findById(idA);
 
         doReturn(Optional.of(GetIdFromStatusResponse.newBuilder()
@@ -1356,6 +1421,8 @@ public class StatusControllerWsRestTest {
         when(this.wsServerConfiguration.getDeclareTokenPrivateKey()).thenReturn(Encoders.BASE64.encode(keyPair.getPrivate().getEncoded()));
         when(this.wsServerConfiguration.getJwtUseTransientKey()).thenReturn(false);
 
+        when(this.wsServerConfiguration.getAnalyticsTokenPrivateKey()).thenReturn(Encoders.BASE64.encode(keyPair.getPrivate().getEncoded()));
+
         // When
         ResponseEntity<StatusResponseDto> response = this.restTemplate.exchange(targetUrl.toString(),
                 HttpMethod.POST, this.requestEntity, StatusResponseDto.class);
@@ -1407,6 +1474,53 @@ public class StatusControllerWsRestTest {
 
     }
 
+    @Test
+    public void when_calling_status_should_return_an_analytics_token() {
 
+        // Given
+        byte[] idA = this.generateKey(5);
+        byte[] kA = this.generateKA();
+
+        Registration reg = Registration.builder()
+                .permanentIdentifier(idA)
+                .atRisk(false)
+                .isNotified(false)
+                .lastStatusRequestEpoch(currentEpoch - 3).build();
+
+        byte[][] reqContent = createEBIDTimeMACFor(idA, kA, currentEpoch);
+
+        statusBody = StatusVo.builder()
+                .ebid(Base64.encode(reqContent[0]))
+                .epochId(currentEpoch)
+                .time(Base64.encode(reqContent[1]))
+                .mac(Base64.encode(reqContent[2])).build();
+
+        this.requestEntity = new HttpEntity<>(this.statusBody, this.headers);
+
+        byte[] decryptedEbid = new byte[8];
+        System.arraycopy(idA, 0, decryptedEbid, 3, 5);
+        System.arraycopy(ByteUtils.intToBytes(currentEpoch), 1, decryptedEbid, 0, 3);
+
+        when(this.wsServerConfiguration.getJwtUseTransientKey()).thenReturn(true);
+
+        doReturn(Optional.of(reg)).when(this.registrationService).findById(idA);
+
+        doReturn(Optional.of(GetIdFromStatusResponse.newBuilder()
+                .setEpochId(currentEpoch)
+                .setIdA(ByteString.copyFrom(idA))
+                .setTuples(ByteString.copyFrom("Base64encodedEncryptedJSONStringWithTuples".getBytes()))
+                .build()))
+                .when(this.cryptoServerClient).getIdFromStatus(any());
+
+        this.requestEntity = new HttpEntity<>(this.statusBody, this.headers);
+
+        // When
+        ResponseEntity<StatusResponseDto> response = this.restTemplate.exchange(this.targetUrl.toString(),
+                HttpMethod.POST, this.requestEntity, StatusResponseDto.class);
+
+        // Then
+        assertEquals(HttpStatus.OK, response.getStatusCode());
+        assertNotNull(response.getBody().getAnalyticsToken());
+    }
 
 }
diff --git a/robert-server/robert-server-ws-rest/src/test/java/test/fr/gouv/stopc/robertserver/ws/StatusControllerWsRestV5ErrorsTest.java b/robert-server/robert-server-ws-rest/src/test/java/test/fr/gouv/stopc/robertserver/ws/StatusControllerWsRestV5ErrorsTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..495239ab988a343e305b8fde2c518066fbe43dd6
--- /dev/null
+++ b/robert-server/robert-server-ws-rest/src/test/java/test/fr/gouv/stopc/robertserver/ws/StatusControllerWsRestV5ErrorsTest.java
@@ -0,0 +1,108 @@
+package test.fr.gouv.stopc.robertserver.ws;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+import java.net.URI;
+
+import javax.inject.Inject;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.boot.test.mock.mockito.SpyBean;
+import org.springframework.boot.test.web.client.TestRestTemplate;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.test.context.TestPropertySource;
+import org.springframework.web.util.UriComponentsBuilder;
+
+import fr.gouv.stopc.robertserver.ws.RobertServerWsRestApplication;
+import fr.gouv.stopc.robertserver.ws.controller.impl.StatusControllerImpl;
+import fr.gouv.stopc.robertserver.ws.service.IRestApiService;
+import fr.gouv.stopc.robertserver.ws.utils.UriConstants;
+import fr.gouv.stopc.robertserver.ws.vo.StatusVo;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+@SpringBootTest(classes = {
+        RobertServerWsRestApplication.class }, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+@TestPropertySource("classpath:application.properties")
+public class StatusControllerWsRestV5ErrorsTest {
+
+    @Value("${controller.path.prefix}" + UriConstants.API_V5)
+    private String pathPrefixV5;
+
+    @Inject
+    private TestRestTemplate restTemplate;
+
+    HttpEntity<StatusVo> requestEntity;
+
+    private URI targetUrl;
+
+    private StatusVo statusBody;
+
+    private HttpHeaders headers;
+
+    @MockBean
+    private IRestApiService restApiService;
+    
+    @SpyBean
+    private StatusControllerImpl statusController;
+
+    @BeforeEach
+    public void setUp() {
+        this.targetUrl = UriComponentsBuilder.fromUriString(this.pathPrefixV5).path(UriConstants.STATUS).build().encode().toUri();
+        this.statusBody = StatusVo.builder()
+                .ebid("012345678912")
+                .epochId(1)
+                .time("12345678")
+                .mac("01234567890123456789012345678901234567891234")
+                .build();
+        this.requestEntity = new HttpEntity<>(this.statusBody, this.headers);
+    }
+  
+    @Test
+    public void testWhenGetStatusReturnsBadRequestThenGetStatusV5ReturnsBadRequest() {
+        when(statusController.getStatus(any())).thenReturn(ResponseEntity.badRequest().build());
+        
+        ResponseEntity<String> response = this.restTemplate.exchange(this.targetUrl, HttpMethod.POST,
+                this.requestEntity, String.class);
+
+        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+    }
+
+    @Test
+    public void testWhenGetStatusReturnsInternalServerErrorThenGetStatusV5ReturnsInternalServerError() {
+        when(statusController.getStatus(any())).thenReturn(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build());
+        
+        ResponseEntity<String> response = this.restTemplate.exchange(this.targetUrl, HttpMethod.POST,
+                this.requestEntity, String.class);
+
+        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
+    }
+
+    @Test
+    public void testWhenGetStatusReturnsNotFoundThenGetStatusV5ReturnsNotFound() {
+        when(statusController.getStatus(any())).thenReturn(ResponseEntity.status(HttpStatus.NOT_FOUND).build());
+        
+        ResponseEntity<String> response = this.restTemplate.exchange(this.targetUrl, HttpMethod.POST,
+                this.requestEntity, String.class);
+
+        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
+    }
+    
+    @Test
+    public void testWhenGetStatusReturnsNullThenGetStatusV5ReturnsInternalServerError() {
+        when(statusController.getStatus(any())).thenReturn(null);
+        
+        ResponseEntity<String> response = this.restTemplate.exchange(this.targetUrl, HttpMethod.POST,
+                this.requestEntity, String.class);
+
+        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
+    }
+}
diff --git a/robert-server/robert-server-ws-rest/src/test/java/test/fr/gouv/stopc/robertserver/ws/UnregisterControllerWsRestTest.java b/robert-server/robert-server-ws-rest/src/test/java/test/fr/gouv/stopc/robertserver/ws/UnregisterControllerWsRestTest.java
index 275abcd290c22ba55ee793cfbced68571c8a8c6e..f74af0d7cfcf09cdbf4d70c6880fd645416033db 100644
--- a/robert-server/robert-server-ws-rest/src/test/java/test/fr/gouv/stopc/robertserver/ws/UnregisterControllerWsRestTest.java
+++ b/robert-server/robert-server-ws-rest/src/test/java/test/fr/gouv/stopc/robertserver/ws/UnregisterControllerWsRestTest.java
@@ -1,5 +1,34 @@
 package test.fr.gouv.stopc.robertserver.ws;
 
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.*;
+
+import java.net.URI;
+import java.security.Key;
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import java.util.Optional;
+
+import javax.crypto.KeyGenerator;
+import javax.inject.Inject;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.boot.test.web.client.TestRestTemplate;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.test.context.TestPropertySource;
+import org.springframework.web.util.UriComponentsBuilder;
+
 import com.google.protobuf.ByteString;
 import fr.gouv.stopc.robert.crypto.grpc.server.client.service.ICryptoServerGrpcClient;
 import fr.gouv.stopc.robert.crypto.grpc.server.messaging.DeleteIdResponse;
@@ -24,28 +53,6 @@ import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.mockito.ArgumentMatchers;
 import org.mockito.MockitoAnnotations;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.beans.factory.annotation.Value;
-import org.springframework.boot.test.context.SpringBootTest;
-import org.springframework.boot.test.mock.mockito.MockBean;
-import org.springframework.boot.test.web.client.TestRestTemplate;
-import org.springframework.http.*;
-import org.springframework.test.context.TestPropertySource;
-import org.springframework.web.util.UriComponentsBuilder;
-
-import javax.crypto.KeyGenerator;
-import javax.inject.Inject;
-import java.net.URI;
-import java.security.Key;
-import java.security.NoSuchAlgorithmException;
-import java.security.SecureRandom;
-import java.util.Optional;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertTrue;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.ArgumentMatchers.anyString;
-import static org.mockito.Mockito.*;
 
 @SpringBootTest(classes = {
         RobertServerWsRestApplication.class }, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@@ -63,6 +70,9 @@ public class UnregisterControllerWsRestTest {
     private String pathPrefixV4;
 
     @Value("${controller.path.prefix}" + UriConstants.API_V5)
+    private String pathPrefixV5;
+
+    @Value("${controller.path.prefix}" + UriConstants.API_V6)
     private String pathPrefix;
 
     @Inject
@@ -159,12 +169,18 @@ public class UnregisterControllerWsRestTest {
         acceptOldEBIDValueEpochSucceeds(UriComponentsBuilder.fromUriString(this.pathPrefixV3).path(UriConstants.UNREGISTER).build().encode().toUri());
     }
 
-    /** Test the access for API V3, should not be used since API V4 */
+    /** Test the access for API V4, should not be used since API V5 */
     @Test
     public void testAccessV4() {
         acceptOldEBIDValueEpochSucceeds(UriComponentsBuilder.fromUriString(this.pathPrefixV4).path(UriConstants.UNREGISTER).build().encode().toUri());
     }
 
+    /** Test the access for API V5, should not be used since API V6 */
+    @Test
+    public void testAccessV5() {
+        acceptOldEBIDValueEpochSucceeds(UriComponentsBuilder.fromUriString(this.pathPrefixV5).path(UriConstants.UNREGISTER).build().encode().toUri());
+    }
+
     /** {@link #acceptOldEBIDValueEpochSucceeds(URI)} and shortcut to test for API V4 exposure */
     @Test
     public void testAcceptOldEBIDValueEpochSucceeds() {
diff --git a/robert-server/robert-server-ws-rest/src/test/resources/application.properties b/robert-server/robert-server-ws-rest/src/test/resources/application.properties
index 283f6a9f5c79982094f43f037c805313400c7662..588ffdf2ac431bed1cc32b7c1205cca8f6df5e44 100644
--- a/robert-server/robert-server-ws-rest/src/test/resources/application.properties
+++ b/robert-server/robert-server-ws-rest/src/test/resources/application.properties
@@ -71,6 +71,6 @@ robert.jwt.lifetime=${ROBERT_JWT_TOKEN_LIFETIME:5}
 robert.server.disable-check-captcha=false
 robert.server.disable-check-token=false
 
-#JWT declare token
-robert.jwt.declare.public-kid=${ROBERT_JWT_TOKEN_DECLARE_PUBLIC_KID:public-kid}
-robert.jwt.declare.private-key=${ROBERT_JWT_TOKEN_DECLARE_PRIVATE_KEY:declarationPrivateKey}
+#JWT analytics
+robert.jwt.analytics.token.private-key=${ROBERT_JWT_TOKEN_ANALYTICS_PRIVATE_KEY:analytics-private-key}
+robert.jwt.analytics.token.lifetime=${ROBERT_JWT_TOKEN_ANALYTICS_LIFETIME:360}
diff --git a/system-test/src/test/java/fr/gouv/tac/systemtest/TimeUtilTest.java b/system-test/src/test/java/fr/gouv/tac/systemtest/TimeUtilTest.java
index 66d56da9900e989492974eaf67085b17ebdaf08c..e184941cb72daa74a39bb22a216a8aba021061d8 100644
--- a/system-test/src/test/java/fr/gouv/tac/systemtest/TimeUtilTest.java
+++ b/system-test/src/test/java/fr/gouv/tac/systemtest/TimeUtilTest.java
@@ -1,16 +1,25 @@
 package fr.gouv.tac.systemtest;
 
-import org.junit.Test;
+import static org.junit.Assert.assertEquals;
 
 import java.time.Instant;
 import java.time.LocalDateTime;
 import java.time.ZoneOffset;
 import java.util.Date;
+import java.util.TimeZone;
 
-import static org.junit.Assert.assertEquals;
+import org.junit.After;
+import org.junit.Test;
 
 public class TimeUtilTest {
 
+    private TimeZone origTimeZone = TimeZone.getDefault();
+
+    @After
+    public void resetTimeZone() {
+        TimeZone.setDefault(origTimeZone);
+    }
+
     @Test
     public void dateToTimestampTestAtOriginOneMinute() {
         LocalDateTime localDateTime= LocalDateTime.parse("1900-01-01T00:01:00");
@@ -49,18 +58,20 @@ public class TimeUtilTest {
 
     @Test
     public void naturalLanguageDateStringToNTPTimestampTest() {
+        //use UTC timezone because on Europe/Paris timezone, there are time changes twice a year leading to the failure of this test.
+        TimeZone.setDefault(TimeZone.getTimeZone("UTC"));
         Long today = TimeUtil.naturalLanguageDateStringToNTPTimestamp("Today at noon") ;
-                Long yesterday = TimeUtil.naturalLanguageDateStringToNTPTimestamp("Yesterday at noon");
+        Long yesterday = TimeUtil.naturalLanguageDateStringToNTPTimestamp("Yesterday at noon");
         assertEquals(new Long(24*3600), new Long(today - yesterday) );
-
     }
 
     @Test
     public void naturalLanguageDateStringToNTPTimestampTest2() {
+        //use UTC timezone because on Europe/Paris timezone, there are time changes twice a year leading to the failure of this test.
+        TimeZone.setDefault(TimeZone.getTimeZone("UTC"));
         Long yesterday = TimeUtil.naturalLanguageDateStringToNTPTimestamp("12:30, 2 day ago") ;
         Long today = TimeUtil.naturalLanguageDateStringToNTPTimestamp("Yesterday at 12:30");
         assertEquals(new Long(24*3600), new Long(today - yesterday) );
-
     }
 
     @Test