Introduction
L'Assessment Partner API permet à un système RH tiers (ATS) ou à vos scripts de piloter Levelia par programme, avec des conventions proches des standards du marché (Greenhouse, SmartRecruiters). Toutes les réponses sont au format JSON.
URL de base : https://app.levelia.fr. L'API est versionnée sous /api/v1.
Toute ressource est scopée à l'organisation de la clé. Une campagne ou une invitation d'une autre organisation est indiscernable d'une ressource inexistante (réponse 404).
Authentification
Chaque requête à l'API partenaire porte une clé API propre à l'organisation dans l'en-tête Authorization. Les clés commencent par le préfixe lvk_.
curl https://app.levelia.fr/api/v1/tests \ -H "Authorization: Bearer lvk_VotreCleApiSecrete"
Une clé absente, invalide, révoquée ou expirée renvoie 401 de façon indiscernable (aucune indication sur l'existence de la clé). Les requêtes sont limitées en débit par clé (voir Limites).
Gestion des clés API
Les clés se gèrent depuis l'application, en session authentifiée (rôle owner, admin ou group_admin). La valeur en clair n'est renvoyée qu'à la création : conservez-la, elle n'est plus récupérable ensuite (stockée hachée).
// Requête
{ "label": "ATS Greenhouse" }
// Réponse 201
{ "id": "…", "label": "ATS Greenhouse", "prefix": "a1b2c3d4",
"key": "lvk_…" // affichée UNE SEULE fois }Liste les clés (préfixe, libellé, état, dates), jamais le secret.
Révoque une clé : l'accès avec cette clé échoue immédiatement.
Lister les épreuves - list_tests
Renvoie les épreuves (campagnes) actives de l'organisation de la clé.
// Réponse 200
{ "tests": [
{ "id": "b3f…", // identifiant de l'épreuve (testId)
"title": "Consultant",
"testParts": ["quiz", "practical"],
"expiresAt": "2026-08-01T10:00:00.000Z",
"remaining": 42 } // invitations restantes (plafond - utilisées)
] }Envoyer un test - send_test
Crée une invitation pour un candidat dans une épreuve. Respecte les mêmes gardes que l'application : épreuve active et non expirée, plafond de la campagne, quota du groupe, et idempotence (un même candidat n'est pas invité deux fois à la même épreuve).
// Requête
{ "email": "candidat@exemple.com",
"firstName": "Alice", // optionnel
"lastName": "Martin" } // optionnel
// Réponse 201
{ "id": "inv_…", // identifiant de suivi (invitationId)
"url": "https://test.levelia.fr/<token>", // lien candidat
"status": "pending" }Un email d'invitation est envoyé au candidat. Le champ id sert ensuite à suivre le statut.
Suivre le statut - test_status
Renvoie l'état courant d'un test envoyé : avancement de l'invitation, du scoring, et le badge émis le cas échéant. Les scores ne sont présents que lorsqu'ils sont prêts et non invalidés (restitution, jamais une décision).
// Réponse 200
{ "id": "inv_…",
"status": "completed", // pending | started | completed | expired | revoked
"scoring": "done", // pending | scoring | done | failed
"badge": { // présent si un badge valide est émis
"credentialId": "LVIA-XXXX-XXXX-XXXX",
"verifyUrl": "https://levelia.fr/v/LVIA-XXXX-XXXX-XXXX" },
"scores": { // présent seulement si scoring = done (non invalidé)
"prm": 72, "vrf": 68, "itr": 80, "ctx": 75,
"eth": 90, "jgm": 65, "int": 78, "aut": 82 },
"reportNotice": "Aide à la décision - aucune décision automatisée (AI Act / RGPD)." }Codes d'erreur
Les erreurs renvoient un corps { error, code } et un statut HTTP :
| Statut | code | Signification |
|---|---|---|
| 401 | unauthorized | Clé absente, invalide, révoquée ou expirée (indiscernable). |
| 402 | quota_exhausted | Quota d'analyses du groupe épuisé. |
| 404 | not_found | Épreuve / invitation inconnue ou hors de l'organisation de la clé. |
| 409 | already_invited | Candidat déjà invité à cette épreuve (idempotence). |
| 422 | max_invitations | Plafond d'invitations de la campagne atteint. |
| 429 | rate_limited | Trop de requêtes pour la clé (voir Limites). |
Limites de débit
L'API publique est limitée en débit par clé (fenêtre fixe). Un dépassement renvoie 429 avec le code rate_limited. La consommation d'analyses (quota du groupe) est indépendante du canal : un test envoyé par l'API consomme le quota exactement comme via l'application.
Webhooks
Plutôt que d'interroger le statut en boucle, enregistrez une URL de webhook et recevez un événement signé quand un score devient prêt (score.ready) ou qu'un badge est émis (badge.issued).
Enregistrer une destination
Gestion en session (rôle owner / admin / group_admin). L'URL doit être en HTTPS publique.
// Requête
{ "url": "https://votre-domaine.com/webhooks/levelia",
"events": ["score.ready", "badge.issued"] } // optionnel (défaut : les deux)
// Réponse 201
{ "id": "…", "url": "…", "events": ["score.ready","badge.issued"],
"secret": "whsec_…" // secret de signature, affiché UNE SEULE fois }Autres opérations : GET /api/settings/webhooks (liste), DELETE /api/settings/webhooks/{id} (désactiver), POST /api/settings/webhooks/{id}/rotate (rotation du secret), GET /api/settings/webhooks/{id}/deliveries (livraisons), et POST /api/settings/webhooks/{id}/deliveries/{deliveryId}/redeliver (redélivrance).
Charge d'un événement
// En-têtes
Content-Type: application/json
X-Levelia-Event: score.ready
X-Levelia-Signature: t=1737300000,v1=<hex HMAC-SHA256>
// Corps
{ "id": "score.ready:<assessmentId>", // identifiant d'événement stable (dédup)
"type": "score.ready", // ou "badge.issued"
"created": "2026-07-19T10:00:00.000Z",
"data": {
// score.ready :
"testId": "inv_…",
"scores": { "prm": 72, "vrf": 68, "itr": 80, "ctx": 75,
"eth": 90, "jgm": 65, "int": 78, "aut": 82 }
// badge.issued :
// "credentialId": "LVIA-XXXX-XXXX-XXXX",
// "verifyUrl": "https://levelia.fr/v/LVIA-XXXX-XXXX-XXXX"
} }Vérifier la signature
Recalculez un HMAC-SHA256 de la chaîne `${t}.${corpsBrut}` avec le secret, comparez-le à v1 (comparaison à temps constant), et rejetez un horodatage t trop ancien (anti-rejeu).
// Node.js
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(rawBody, header, secret, toleranceS = 300) {
const parts = Object.fromEntries(
header.split(",").map((s) => s.split("=")));
const t = Number(parts.t);
if (Math.abs(Date.now() / 1000 - t) > toleranceS) return false; // anti-rejeu
const expected = createHmac("sha256", secret)
.update(t + "." + rawBody).digest("hex");
const a = Buffer.from(expected), b = Buffer.from(parts.v1);
return a.length === b.length && timingSafeEqual(a, b);
}Fiabilité et sécurité de livraison
La livraison est asynchrone (après commit, ne bloque jamais le traitement interne), au moins une fois - dédupliquez sur id. En cas d'échec, Levelia réessaie avec un backoff borné puis journalise (redélivrance possible). Les redirections ne sont pas suivies et l'URL est validée (les cibles internes / privées sont refusées).