{"openapi":"3.1.0","info":{"title":"API voc.nc — formulaires","version":"1.2.0","summary":"Génération de liens de réponse et lecture des résultats d'un formulaire de satisfaction.","description":"L'API v1 expose les formulaires d'une organisation : découverte, génération de liens de réponse\nà usage unique (`/r/:token`) à distribuer par le canal de votre choix, suivi des campagnes et de\nleurs liens, et lecture des résultats — réponses individuelles et agrégats par indicateur.\n\n## Authentification\n\nChaque requête porte une clé d'API dans l'en-tête `Authorization: Bearer \u003cclé\u003e`. La clé se crée\ndans l'interface : **Organisation → API → Clés**, avec une permission `read` (lecture) ou\n`write` (lecture et génération de liens). Elle n'est affichée qu'une fois ; la plateforme n'en\nconserve qu'une empreinte SHA-256 et ses 6 derniers caractères, que la liste des clés affiche\npour que vous la reconnaissiez. Une clé révoquée, inconnue ou absente répond le même\n`401 unauthorized` : le refus ne dit jamais si la clé a existé.\n\n## Limites de débit\n\n| Portée | Limite | Réponse |\n|---|---|---|\n| Toute l'API, par clé — générations de liens comprises | 100 requêtes par minute | `429 rate_limited` + `Retry-After` |\n| `POST /forms/{id}/contacts`, par clé | 20 000 **liens** par minute | `429 rate_limited` + `Retry-After` |\n| Toute l'API, par adresse IP | 300 requêtes par minute | `429 rate_limited` + `Retry-After` |\n\nLes fenêtres sont des minutes d'horloge. L'en-tête `Retry-After` (secondes) indique la fin de la\nfenêtre courante. Aucun en-tête `X-RateLimit-*` n'est émis en v1.\n\n**La génération de liens est limitée en liens, pas en appels.** Un appel compte pour le nombre\nde liens qu'il demande (`count`, ou le nombre d'entrées de `contacts`). Un appel qui demande plus\nque le reste du budget de la minute est **refusé en entier** — aucun lien créé — et ne consomme\nrien : renvoyez le même appel à la fenêtre suivante. Un appel au maximum (10 000 liens) passe\ndonc au plus tard à la minute suivante, tant que vos autres générations restent sous 10 000\nliens par minute. Un appel au-delà de 10 000 liens n'est jamais admis : `422 too_many_contacts`.\n\nLa taille d'un corps JSON est plafonnée à 4 Mo sur toute opération : au-delà, `413\npayload_too_large`, refusé avant tout décodage du corps et avant l'authentification. La longueur maximale d'une\n`reference` se compte en caractères, pas en octets : 10 000 références longues et accentuées\npeuvent dépasser ce plafond — découpez alors l'appel.\n\nLa limite par adresse IP est commune à tous les clients derrière une même adresse : plusieurs\nintégrations qui sortent par la même passerelle se partagent ses 300 requêtes par minute.\n\n## Erreurs\n\nToute erreur a la même forme : `{ \"error\": { \"code\": \"\u003ccode stable\u003e\", \"status\": \u003cstatut HTTP\u003e,\n\"message\": \"\u003ctexte en français\u003e\" } }`, y compris le `429` de la limite de débit. Le `code` est stable et fait partie du contrat ; le `message` peut changer. Une\nressource absente ou appartenant à une autre organisation répond `404 not_found` — jamais 403.\n\n**Une requête hors contrat est refusée, jamais corrigée ni ignorée** :\n\n| Requête | Réponse |\n|---|---|\n| Paramètre de requête absent de la liste de l'opération (`_=` et `utm_*` compris) | `400 unknown_parameter` |\n| `per` hors de 1–500 ou non entier ; `page` inférieur à 1 ou non entier ; paramètre répété ou structuré (`page[]=2`) | `400 bad_request` |\n| `from` postérieur à `to` (`from` égal à `to` désigne une journée) | `400 bad_request` |\n| `from` ou `to` hors des formats `YYYY-MM-DD` et `YYYY-MM-DDThh:mm[:ss[.fraction]]` (suivi ou non de `Z` ou `±hh:mm`), ou jour absent du calendrier ; `window` hors de `3`, `6`, `12`, `all` | `400 bad_request` |\n| Filtre qui désigne une ressource absente ou d'une autre organisation (`GET /campaigns?form_id=`) | `404 not_found` |\n| Page au-delà de la dernière | `200`, `data` vide, `meta` rempli : `meta.total` dit que le jeu n'est pas vide |\n\n## Formulaires anonymes\n\nSur un formulaire anonyme, **la plateforme supprime le lien entre le token et la réponse, et\ntoute restitution est soumise au seuil minimal d'occurrences *k***, fixé à 5. Un formulaire\nanonyme porte une **date de fin** : avant elle,\n`GET /forms/{id}/results` ne rend aucune réponse et masque ses agrégats ; après, le seuil se\ncompte sur le total des réponses du formulaire — en dessous rien n'est restitué, à partir de *k*\ntout l'est, réponses individuelles comprises. Le motif se lit une fois, en tête de la réponse,\ndans `masking` : `before_end_date` avec la date de fin à partir de laquelle revenir,\n`below_anonymity_threshold`, ou `responses_purged` ; `masking` vaut `null` quand rien n'est\nretenu. `meta.total` compte les réponses restituées : `0` sur un formulaire retenu, que\n`masking` distingue d'un formulaire sans réponse. Les agrégats sont calculés sur l'une des fenêtres de\ncalcul de l'écran (3, 6, 12 derniers mois, tout l'historique), indiquée dans\n`aggregates_window`. Toutes les réponses portent la date de fin (`completed_at` au jour). Passé\ncette date, aucun lien ne se génère plus (`422 form_closed`) — et un formulaire anonyme publié\n**sans** date de fin n'en génère aucun non plus, sous le même code : tant qu'aucune date n'est\nposée, rien n'y sera jamais consultable. Les deux causes se distinguent au `message`, jamais au\n`code`. La fiche de la campagne (`GET /campaigns/{id}`) rend le statut réel de chaque lien. Ces\nmesures ne réécrivent pas le contenu des réponses. Un commentaire comme « en tant que seul comptable de l'agence de Koné, je trouve que mon manager… » désigne son\nauteur, et plusieurs réponses à choix croisées (service, ancienneté, site) peuvent désigner une\nseule personne. L'organisation utilisatrice s'engage, par les CGU, à ne pas contourner\nl'anonymat pour identifier un répondant.\n\n**Un formulaire anonyme clos sous le seuil voit ses réponses supprimées** trois mois après sa\ndate de fin. Partent alors les réponses, leurs valeurs par question et leurs textes libres ;\nrestent le formulaire, ses liens et leur statut. C'est définitif : `masking.reason` passe de\n`below_anonymity_threshold` à `responses_purged`, et n'attendez plus de restitution. Ces\nréponses n'étaient de toute façon jamais consultables — le formulaire ne se rouvrant pas, le\nseuil ne pouvait plus être atteint, et les conserver n'aurait exposé que leurs auteurs. C'est la\nseule suppression de réponses que la plateforme pratique.\n\n## Horodatages\n\nLes horodatages complets sont rendus en UTC, suffixe `Z` (`date-time`). Les paramètres `from` et\n`to` sans indication de fuseau s'interprètent au fuseau de l'organisation ; un offset explicite\nl'emporte. Donnés en date nue (`YYYY-MM-DD`), ils désignent une journée entière de ce fuseau. Sur un formulaire anonyme, `completed_at` est une date nue (`date`, `YYYY-MM-DD`) au\njour local de l'organisation.\n\n## Compatibilité\n\n- La v1 est gelée à sa publication : aucun champ, aucune valeur d'énumération, aucun code HTTP\n  documenté ici n'est retiré ni renommé.\n- Seuls des ajouts sont possibles (nouveaux champs, nouveaux endpoints, nouveaux paramètres\n  optionnels) ; un client doit ignorer les champs qu'il ne connaît pas.\n- **Une énumération peut recevoir de nouvelles valeurs** : un code d'erreur, un motif de\n  masquage ou un statut peut s'ajouter à ceux publiés ici. Aucun ne sera jamais retiré ni\n  renommé. **Prévoyez donc un cas par défaut** pour une valeur que vous ne connaissez pas — un\n  `switch` sans branche finale tombera le jour où le produit nommera une situation de plus.\n- Toute rupture passe par `/api/v2`.\n- Chaque changement est daté dans l'[historique des versions](/developers#historique) de la\n  page `/developers`.\n","contact":{"name":"RESILIENCE NC","url":"https://voc.nc/developers"},"license":{"name":"Propriétaire — RESILIENCE NC","identifier":"LicenseRef-Proprietary"}},"servers":[{"url":"https://next.voc.nc/api/v1","description":"next.voc.nc"}],"security":[{"bearerAuth":[]}],"tags":[{"name":"Formulaires","description":"Découverte des formulaires de l'organisation."},{"name":"Liens de réponse","description":"Génération de liens de réponse à usage unique."},{"name":"Campagnes","description":"Suivi d'une vague de liens — ses compteurs, ses liens et leurs statuts."},{"name":"Résultats","description":"Réponses individuelles et agrégats par indicateur."},{"name":"Contrat","description":"Le présent document, servi par l'API."}],"paths":{"/forms":{"get":{"tags":["Formulaires"],"operationId":"listForms","summary":"Lister les formulaires de l'organisation","description":"Liste minimale, paginée, des formulaires de l'organisation à laquelle appartient la clé. Le\nchamp `anonymous` prévient d'avance que `results` ne restituera rien avant la date de fin du\nformulaire, ni sous le seuil *k*. Tri : date de création décroissante.\n","parameters":[{"name":"status","in":"query","required":false,"description":"Ne retourne que les formulaires dans cet état.","schema":{"$ref":"#/components/schemas/FormStatus"}},{"$ref":"#/components/parameters/Page"},{"$ref":"#/components/parameters/Per"}],"responses":{"200":{"description":"Liste des formulaires.","content":{"application/json":{"schema":{"type":"object","required":["data","meta"],"properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Form"}},"meta":{"$ref":"#/components/schemas/PaginationMeta"}}},"examples":{"deux formulaires":{"value":{"data":[{"id":12,"name":"Satisfaction accueil","status":"published","anonymous":false,"ends_at":null,"closed":false,"created_at":"2026-08-18T03:12:44Z"},{"id":15,"name":"Baromètre interne","status":"published","anonymous":true,"ends_at":"2026-09-30T12:59:59Z","closed":true,"created_at":"2026-08-20T21:05:10Z"}],"meta":{"total":2,"page":1,"per":100,"pages":1}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"413":{"$ref":"#/components/responses/PayloadTooLarge"},"429":{"$ref":"#/components/responses/TooManyRequests"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/forms/{id}/contacts":{"post":{"tags":["Liens de réponse"],"operationId":"createLinks","summary":"Générer des liens de réponse","description":"Génère un lien de réponse à usage unique par entrée, à distribuer par\nle canal de votre choix. Deux modes exclusifs : `contacts` (un lien par référence — la\n`reference` est un identifiant qui n'a de sens que pour vous, elle est renvoyée dans la\nréponse et **jamais conservée** par la plateforme) ou `count` (N liens sans référence).\n\nPermission `write` requise. Le formulaire doit être publié. Au plus **10 000** liens par\nappel, dans un corps de **4 Mo** au maximum (`413 payload_too_large` au-delà, avant tout\ndécodage du corps). L'appel est synchrone.\n\n**Campagne** : les liens rejoignent une campagne de votre organisation, une par clé d'API,\nformulaire et `campaign_name`. Sans `campaign_name`, tous les appels d'une même clé sur un\nmême formulaire rejoignent la même campagne, nommée « nom de la clé — nom du formulaire »\nà sa création. Avec `campaign_name`, les appels portant exactement ce nom se regroupent :\nc'est ainsi que vous découpez vos vagues. Une nouvelle clé ouvre ses propres campagnes.\n\n**Non idempotent** : un appel rejoué (par exemple après un délai d'attente réseau) ajoute\nautant de liens de plus à la même campagne.\n\n**Solde de liens** : à partir de la date de début du décompte, chaque lien généré est\nretiré du solde de liens de votre organisation. Un appel qui porterait ce solde sous zéro\nest refusé en entier (`402 insufficient_balance`) : aucun lien n'est créé, et l'appel\nn'entame pas le budget de liens par minute. À partir de cette même date, la réponse réussie\nporte le bloc `balance` : le solde restant après l'appel, et `low` à `true` quand il est de\n10 000 liens ou moins. Avant, le bloc est absent.\n\n**Durée de vie des liens** : chaque lien rendu porte son `expires_at`, qui vaut\n`min(création + 3 mois, date de fin du formulaire)` — sur un formulaire anonyme se terminant\ndans huit jours, vos liens valent huit jours. Prolonger la date de fin d'un formulaire\n**redate tous ses liens déjà distribués**, sans qu'aucun appel vous en avertisse : c'est un\ngeste courant côté organisation, relisez l'échéance sur `GET /campaigns/{id}` si elle compte\npour vous.\n","x-idempotent":false,"parameters":[{"$ref":"#/components/parameters/FormId"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateLinksRequest"},"examples":{"par références":{"summary":"Un lien par référence client","value":{"campaign_name":"Relance dossiers juillet","contacts":[{"reference":"DOSSIER-2026-0412"},{"reference":"DOSSIER-2026-0413"}]}},"par nombre":{"summary":"N liens sans référence (affichage, QR code…)","value":{"count":50}}}}}},"responses":{"201":{"description":"Liens créés, dans l'ordre du corps de la requête.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateLinksResponse"},"examples":{"par références":{"value":{"data":{"created":[{"reference":"DOSSIER-2026-0412","token":"3f9a1c0b7e2d","url":"https://voc.nc/r/3f9a1c0b7e2d","expires_at":"2026-12-08T21:14:03Z"},{"reference":"DOSSIER-2026-0413","token":"a81d55e0c4f7","url":"https://voc.nc/r/a81d55e0c4f7","expires_at":"2026-12-08T21:14:03Z"}]},"meta":{"total":2,"campaign_id":87,"campaign_name":"Relance dossiers juillet"}}},"par nombre":{"value":{"data":{"created":[{"reference":null,"token":"5c02e9b1d7a4","url":"https://voc.nc/r/5c02e9b1d7a4","expires_at":"2026-10-31T13:00:00Z"}]},"meta":{"total":1,"campaign_id":88,"campaign_name":"CRM — Enquête satisfaction accueil"}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"402":{"$ref":"#/components/responses/PaymentRequired"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"413":{"$ref":"#/components/responses/PayloadTooLarge"},"422":{"$ref":"#/components/responses/Unprocessable"},"429":{"$ref":"#/components/responses/TooManyRequests"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/forms/{id}/results":{"get":{"tags":["Résultats"],"operationId":"getResults","summary":"Lire les résultats d'un formulaire","description":"Réponses individuelles paginées (`data`) et agrégats par couple (indicateur, périmètre)\n(`aggregates`), indépendants de la pagination.\n\n**`data` et `aggregates` ne couvrent pas la même période par défaut**, comme la liste des\nréponses et les cartes du rapport à l'écran. Sans `from` ni `to`, `data` couvre **tout\nl'historique** du formulaire et `aggregates` la fenêtre `window` (**12 mois** par défaut) :\nrecalculer un indicateur sur toutes les pages ne redonne pas le chiffre de `aggregates`. Sur\nun formulaire nominatif, `from` / `to` bornent les deux. `aggregates_window` dit toujours la\npériode réelle des agrégats.\n\n**La pagination n'est pas stable pendant un parcours.** Les réponses sont triées de la plus\nrécente à la plus ancienne : une réponse qui arrive entre deux pages décale les suivantes\n(la page suivante répète la dernière réponse lue), une réponse supprimée les fait remonter\n(la page suivante en saute une). Pour un parcours complet, **fixez `to` à l'instant du début\ndu parcours** et gardez-le sur chaque page : les réponses arrivées ensuite n'entrent pas dans\nle jeu. Une suppression reste possible, mais rare sur la durée d'un parcours.\n\nSur un formulaire anonyme : `data` est vide avant la date de fin du formulaire, et sous le\nseuil *k* compté sur le total de ses réponses ; au-delà, chaque réponse est restituée en\nclair. `completed_at` est une date au jour, les bornes `from` / `to` sont élargies à la\njournée et ne filtrent que `data` et `meta` ; `aggregates` est calculé sur la fenêtre de\ncalcul de l'écran qui contient la période demandée (`aggregates_window`).\n\nDeux mécanismes distincts expliquent un `score` à `null` :\n\n- `masking`, en tête de la réponse, non `null` : masquage d'anonymat, décidé une fois pour\n  tout le formulaire. `data` est vide, `meta.total` vaut `0`, et chaque entrée de\n  `aggregates` a `n`, `score` et `distribution` à `null`. `masking.reason` vaut\n  `before_end_date` (revenir après `masking.ends_at`), `below_anonymity_threshold` ou\n  `responses_purged` (les réponses ont été supprimées et ne reviendront pas) ;\n- `insufficient_n: true` sur une entrée (formulaires nominatifs comme anonymes) : plancher\n  de fiabilité — moins de 5 réponses, `score` est `null` mais `distribution` et `n` sont\n  conservés.\n","parameters":[{"$ref":"#/components/parameters/FormId"},{"name":"from","in":"query","required":false,"description":"Borne basse sur `completed_at` (incluse). Une date `YYYY-MM-DD` qui existe au calendrier, ou une date-heure `YYYY-MM-DDThh:mm[:ss[.fraction]]` suivie ou non de `Z` ou `±hh:mm` (le `+` s'encode `%2B` dans l'adresse) ; toute autre forme est refusée en `400 bad_request`. Sans fuseau, interprétée au fuseau de l'organisation. Une date nue (`YYYY-MM-DD`) désigne le début de cette journée, sur tout formulaire ; une date-heure reste un instant sur un formulaire nominatif et s'élargit au début de journée sur un formulaire anonyme.\n","schema":{"type":"string","examples":["2026-08-01","2026-08-01T00:00:00+11:00"]}},{"name":"to","in":"query","required":false,"description":"Borne haute sur `completed_at` (incluse). Mêmes formats et mêmes règles que `from`, à la fin de la journée : une date nue en désigne la fin sur tout formulaire, une date-heure s'élargit à la fin de journée sur un formulaire anonyme. `from` et `to` égaux, en date nue, désignent donc une journée. Antérieure à `from` : `400 bad_request`.\n","schema":{"type":"string","examples":["2026-08-31"]}},{"name":"window","in":"query","required":false,"description":"Fenêtre de calcul des agrégats, en mois glissants ancrés sur aujourd'hui, ou `all` pour tout l'historique. Défaut `12`, **le même que le rapport du formulaire à l'écran** : à fenêtre égale, les deux affichent les mêmes chiffres. `from` / `to`, quand ils sont fournis, l'emportent sur `window` et bornent aussi les agrégats — sauf sur formulaire anonyme, où les agrégats sont ramenés à la fenêtre offerte la plus étroite qui contient la période demandée (voir `aggregates_window`). Toute autre valeur que les quatre admises, `03` compris : `400 bad_request`.\n","schema":{"type":"string","enum":["3","6","12","all"],"default":"12"}},{"name":"filter_by","in":"query","required":false,"description":"Champ filtré par `from` / `to`. Seule valeur admise en v1.","schema":{"type":"string","enum":["completed_at"],"default":"completed_at"}},{"$ref":"#/components/parameters/Page"},{"$ref":"#/components/parameters/Per"}],"responses":{"200":{"description":"Résultats du formulaire.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ResultsResponse"},"examples":{"formulaire nominatif":{"value":{"data":[{"public_id":"6f1c2b7e-3a9d-4c1e-9b0a-2d7f8e5a1c33","token":"3f9a1c0b7e2d","completed_at":"2026-08-18T03:12:44Z","answers":[{"form_field_id":41,"label":"Êtes-vous satisfait de l'accueil ?","indicator_type":"csat_indicator","scope":{"id":3,"name":"Accueil"},"value":4,"verbatim":"Très bon accueil","detail":null}]}],"meta":{"total":1,"page":1,"per":100,"pages":1},"aggregates_window":{"window":"12","from":"2025-09-08","to":"2026-09-08"},"masking":null,"aggregates":[{"indicator_type":"csat_indicator","scope":{"id":3,"name":"Accueil"},"threshold":null,"n":1,"methodo_warning":false,"insufficient_n":true,"score":null,"distribution":{"4":1}}]}},"formulaire anonyme avant sa date de fin":{"description":"Le formulaire a reçu des réponses, mais sa date de fin n'est pas passée : aucune réponse n'est restituée, `masking` dit pourquoi et jusqu'à quand, et l'agrégat se tait sans publier de compteur.\n","value":{"data":[],"meta":{"total":0,"page":1,"per":100,"pages":0},"aggregates_window":{"window":"12","from":"2025-09-08","to":"2026-09-08"},"masking":{"reason":"before_end_date","ends_at":"2026-09-30T12:59:59Z"},"aggregates":[{"indicator_type":"enps_indicator","scope":null,"threshold":5,"n":null,"methodo_warning":false,"insufficient_n":false,"score":null,"distribution":null}]}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"413":{"$ref":"#/components/responses/PayloadTooLarge"},"429":{"$ref":"#/components/responses/TooManyRequests"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/campaigns":{"get":{"tags":["Campagnes"],"operationId":"listCampaigns","summary":"Lister les campagnes de l'organisation","description":"Liste paginée des campagnes de l'organisation, avec le décompte de leurs liens par statut.\nUne campagne est une **vague** : le lot de liens produit par un même appel, ou par une suite\nd'appels portant le même `campaign_name`. Les campagnes créées depuis l'interface y figurent\naussi.\n\nAdditionnez vos propres vagues pour obtenir votre taux de réponse : nous ne publions aucun\nchiffre au niveau du formulaire, qui mélangerait vos liens et ceux générés depuis\nl'interface. Tri : date de création décroissante.\n","parameters":[{"name":"status","in":"query","required":false,"description":"Ne retourne que les campagnes à ce statut — le même filtre qu'à l'écran. Voir `CampaignStatus`.\n","schema":{"$ref":"#/components/schemas/CampaignStatus"}},{"name":"form_id","in":"query","required":false,"description":"Ne retourne que les campagnes de ce formulaire. Un formulaire absent ou d'une autre organisation : `404 not_found`.\n","schema":{"type":"integer","minimum":1}},{"$ref":"#/components/parameters/Page"},{"$ref":"#/components/parameters/Per"}],"responses":{"200":{"description":"Liste des campagnes.","content":{"application/json":{"schema":{"type":"object","required":["data","meta"],"properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Campaign"}},"meta":{"$ref":"#/components/schemas/PaginationMeta"}}},"examples":{"deux vagues":{"value":{"data":[{"id":88,"name":"Relance dossiers juillet","form_id":12,"status":"processing","created_at":"2026-07-01T08:00:00Z","links_total":120,"links_pending":74,"links_responded":41,"links_expired":3,"links_opted_out":2}],"meta":{"total":1,"page":1,"per":100,"pages":1}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"413":{"$ref":"#/components/responses/PayloadTooLarge"},"429":{"$ref":"#/components/responses/TooManyRequests"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/campaigns/{id}":{"get":{"tags":["Campagnes"],"operationId":"getCampaign","summary":"Suivre une campagne et ses liens","description":"Détail d'une campagne : ses compteurs par statut et la liste paginée de ses liens, chacun à\nla forme `LinkStatus` — c'est le seul endpoint qui rend l'état d'un lien. C'est la réponse à\n« qu'est devenue ma vague de 2 000 liens ? » en une requête au lieu de 2 000. Ce que la vague\na **répondu** ne se lit pas par campagne en v1, pas plus qu'à l'écran.\n\n**Deux surprises à connaître.** Vingt-quatre mois après leur échéance, les liens sont\nsupprimés par la purge des données de contact — pas ceux d'un formulaire en examen, tant\nque dure l'examen : `links_total` les compte toujours, mais les\ncompteurs par statut et la liste des liens d'une vieille campagne retombent à zéro, alors\nque les réponses, elles, restent lisibles sur le formulaire. Et sur un formulaire anonyme\navant sa date de fin, cette\npage annonce des liens répondus pendant que `results` ne rend encore aucune réponse : c'est\nla même asymétrie qu'à l'écran, l'organisation sait qui a participé, jamais ce qu'une\npersonne a répondu.\n","parameters":[{"$ref":"#/components/parameters/CampaignId"},{"$ref":"#/components/parameters/Page"},{"$ref":"#/components/parameters/Per"}],"responses":{"200":{"description":"Détail de la campagne et page de ses liens.","content":{"application/json":{"schema":{"type":"object","required":["data","meta"],"properties":{"data":{"$ref":"#/components/schemas/CampaignDetail"},"meta":{"$ref":"#/components/schemas/PaginationMeta"}}},"examples":{"une vague et ses liens":{"value":{"data":{"id":88,"name":"Relance dossiers juillet","form_id":12,"status":"processing","created_at":"2026-07-01T08:00:00Z","links_total":2,"links_pending":1,"links_responded":1,"links_expired":0,"links_opted_out":0,"links":[{"token":"3f9a1c0b7e2d","status":"pending","created_at":"2026-07-01T08:00:00Z","expires_at":"2026-10-01T08:00:00Z","sent_at":null,"completed_at":null},{"token":"a81d55e0c4f7","status":"responded","created_at":"2026-07-01T08:00:00Z","expires_at":"2026-10-01T08:00:00Z","sent_at":null,"completed_at":"2026-07-02T02:41:57Z"}]},"meta":{"total":2,"page":1,"per":100,"pages":1}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"413":{"$ref":"#/components/responses/PayloadTooLarge"},"429":{"$ref":"#/components/responses/TooManyRequests"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/openapi.json":{"get":{"tags":["Contrat"],"operationId":"getOpenApi","summary":"Le présent contrat, au format JSON","description":"Sert ce document converti en JSON, sans authentification ni cookie. C'est la source que la page publique `/developers` affiche.\n","security":[],"responses":{"200":{"description":"Document OpenAPI 3.1.","content":{"application/json":{"schema":{"type":"object","required":["openapi","info","paths"],"properties":{"openapi":{"type":"string","const":"3.1.0"},"info":{"type":"object"},"paths":{"type":"object"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"413":{"$ref":"#/components/responses/PayloadTooLarge"},"429":{"$ref":"#/components/responses/TooManyRequests"},"500":{"$ref":"#/components/responses/InternalError"}}}}},"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","description":"Clé d'API de l'organisation, créée dans l'interface (Organisation → API → Clés), avec la permission `read` ou `write`. À transmettre dans `Authorization: Bearer \u003cclé\u003e`.\n"}},"parameters":{"FormId":{"name":"id","in":"path","required":true,"description":"Identifiant du formulaire, tel que rendu par `GET /forms`.","schema":{"type":"integer","minimum":1}},"CampaignId":{"name":"id","in":"path","required":true,"description":"Identifiant de la campagne, tel que rendu par `GET /campaigns` ou par `meta.campaign_id` à la génération de liens.","schema":{"type":"integer","minimum":1}},"Page":{"name":"page","in":"query","required":false,"description":"Numéro de page, entier à partir de 1 (sinon `400 bad_request`). Au-delà de la dernière page : `data` vide, `meta` rempli.\n","schema":{"type":"integer","minimum":1,"default":1}},"Per":{"name":"per","in":"query","required":false,"description":"Taille de page, entier de 1 à 500 (sinon `400 bad_request`, jamais ramené à la borne).","schema":{"type":"integer","minimum":1,"maximum":500,"default":100}}},"responses":{"BadRequest":{"description":"Requête invalide — paramètre manquant, mal formé ou hors contrat.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"paramètre hors contrat":{"value":{"error":{"code":"unknown_parameter","status":400,"message":"Paramètre inconnu : contacts[0].label."}}},"corps invalide":{"value":{"error":{"code":"bad_request","status":400,"message":"Fournissez soit `contacts`, soit `count`."}}}}}}},"Unauthorized":{"description":"Clé absente, inconnue ou révoquée — un seul code et un seul message pour les trois cas : distinguer la clé révoquée de la clé inconnue confirmerait à un tiers que la clé a existé.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"refus d'authentification":{"value":{"error":{"code":"unauthorized","status":401,"message":"Clé d'API absente ou invalide."}}}}}}},"Forbidden":{"description":"La clé n'a pas la permission requise, ou l'organisation n'a pas accepté la dernière version des documents légaux — dans ce second cas, **toutes les écritures de l'organisation sont suspendues** jusqu'à ce qu'un de ses administrateurs accepte à l'écran, à l'adresse rendue dans `acceptation_url`. Les lectures, elles, continuent de passer. Troisième cas : le compte est suspendu à la suite d'un impayé (`payment_overdue`) — toutes ses écritures sont refusées, sans `acceptation_url`, jusqu'à ce que RESILIENCE lève la suspension ; ce refus prime sur le précédent.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"permission insuffisante":{"value":{"error":{"code":"forbidden","status":403,"message":"Permission insuffisante : cette opération exige une clé `write`."}}},"documents légaux à accepter":{"description":"Rien à corriger dans votre appel : un administrateur de l'organisation doit accepter les nouvelles conditions dans l'interface. Rejouez ensuite la requête à l'identique.\n","value":{"error":{"code":"terms_acceptance_required","status":403,"message":"Les écritures par l'API sont suspendues jusqu'à ce qu'un administrateur de l'organisation accepte les nouvelles versions de nos documents. Les lectures restent ouvertes.","acceptation_url":"https://voc.nc/legal/acceptation"}}},"compte suspendu pour impayé":{"description":"Rien à corriger dans votre appel : le compte est suspendu à la suite d'un impayé. Contactez RESILIENCE ; une fois la suspension levée, rejouez la requête à l'identique.\n","value":{"error":{"code":"payment_overdue","status":403,"message":"Les écritures par l'API sont suspendues à la suite d'un impayé. Les lectures restent ouvertes."}}}}}}},"NotFound":{"description":"Ressource absente, ou appartenant à une autre organisation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"introuvable":{"value":{"error":{"code":"not_found","status":404,"message":"Ressource introuvable."}}}}}}},"Unprocessable":{"description":"Requête bien formée mais refusée par une règle du produit.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"formulaire non publié":{"value":{"error":{"code":"form_not_published","status":422,"message":"Le formulaire n'est pas publié : aucun lien ne peut être généré."}}},"formulaire clos":{"summary":"Première cause — la date de fin est passée","value":{"error":{"code":"form_closed","status":422,"message":"Le formulaire est clos depuis sa date de fin : aucun lien ne peut être généré."}}},"formulaire anonyme sans date de fin":{"summary":"Seconde cause — formulaire anonyme publié, aucune date de fin posée","description":"Même code, autre cause : ne cherchez pas une date de fin dépassée, il n'y en a aucune. Posez-en une dans l'interface — sans elle, les réponses de ce formulaire ne seraient jamais consultables.\n","value":{"error":{"code":"form_closed","status":422,"message":"Le formulaire anonyme n'a pas de date de fin : aucun lien ne peut être généré."}}},"formulaire sans question":{"value":{"error":{"code":"form_without_questions","status":422,"message":"Le formulaire n'a aucune question : aucun lien ne peut être généré."}}},"trop de liens":{"value":{"error":{"code":"too_many_contacts","status":422,"message":"10 001 entrées : 10 000 au maximum par appel."}}}}}}},"PaymentRequired":{"description":"Solde de liens insuffisant : la génération porterait le solde de l'organisation sous zéro. Elle est refusée en entier, aucun lien n'est créé. Le `message` chiffre le refus : les liens demandés et le solde restant.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"solde insuffisant":{"value":{"error":{"code":"insufficient_balance","status":402,"message":"Solde insuffisant : cette génération demande 101 liens, il vous en reste 100."}}}}}}},"PayloadTooLarge":{"description":"Corps JSON au-delà de 4 Mo, refusé avant tout décodage du corps et avant l'authentification. Sur `POST /forms/{id}/contacts`, découpez votre appel en plusieurs vagues.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"corps trop grand":{"value":{"error":{"code":"payload_too_large","status":413,"message":"Corps de requête trop grand : 4 Mo au maximum."}}}}}}},"TooManyRequests":{"description":"Limite de débit atteinte. `Retry-After` indique, en secondes, la fin de la fenêtre.","headers":{"Retry-After":{"description":"Secondes avant la fin de la fenêtre de débit courante.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"limite atteinte":{"value":{"error":{"code":"rate_limited","status":429,"message":"Trop de requêtes — réessayez plus tard."}}}}}}},"InternalError":{"description":"Erreur imprévue de la plateforme, signalée à notre supervision. Le message ne dit rien de la cause ; la requête peut être renvoyée plus tard.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"erreur interne":{"value":{"error":{"code":"internal_error","status":500,"message":"Erreur interne — réessayez plus tard."}}}}}}}},"schemas":{"Error":{"type":"object","description":"Forme unique de toute réponse d'erreur de l'API v1.","required":["error"],"additionalProperties":false,"properties":{"error":{"type":"object","required":["code","status","message"],"additionalProperties":false,"properties":{"code":{"type":"string","description":"Code stable, en anglais — fait partie du contrat.","enum":["unauthorized","forbidden","not_found","bad_request","unknown_parameter","form_not_published","form_closed","form_without_questions","too_many_contacts","insufficient_balance","payload_too_large","terms_acceptance_required","payment_overdue","rate_limited","internal_error"]},"status":{"type":"integer","minimum":400,"maximum":599,"description":"Le statut HTTP de la réponse, répété dans le corps pour l'outil qui ne conserve que lui. Chaque `code` correspond à un seul statut.\n"},"message":{"type":"string","description":"Explication en français, destinée à un humain — peut changer."},"acceptation_url":{"type":"string","format":"uri","description":"Adresse absolue de la page où accepter les documents légaux. Présente sur le seul code `terms_acceptance_required`, absente partout ailleurs — vous n'avez pas de base d'URL pour reconstruire ce chemin, et l'interlocuteur technique d'une intégration n'a souvent pas de compte sur la plateforme.\n"}}}}},"PaginationMeta":{"type":"object","required":["total","page","per","pages"],"properties":{"total":{"type":"integer","minimum":0,"description":"Nombre total d'éléments, toutes pages confondues."},"page":{"type":"integer","minimum":1},"per":{"type":"integer","minimum":1,"maximum":500},"pages":{"type":"integer","minimum":0}}},"FormStatus":{"type":"string","enum":["draft","published","archived"]},"Form":{"type":"object","required":["id","name","status","anonymous","ends_at","closed","created_at"],"properties":{"id":{"type":"integer"},"name":{"type":"string"},"status":{"$ref":"#/components/schemas/FormStatus"},"anonymous":{"type":"boolean","description":"Vrai si le formulaire est anonyme (déclaré tel, ou porteur d'une question eNPS) : `results` n'y restitue rien avant la date de fin, ni sous le seuil *k*.\n"},"ends_at":{"type":["string","null"],"format":"date-time","description":"Date de fin du formulaire, au-delà de laquelle aucun lien ne se génère plus. `null` sur un formulaire qui n'en a pas — le cas des formulaires nominatifs. Elle plafonne aussi l'échéance des liens déjà émis, et l'organisation peut la déplacer.\n"},"closed":{"type":"boolean","description":"Vrai quand `ends_at` est passée. La clôture est ce que le calendrier a fait, `status` ce que l'utilisateur a décidé : **un formulaire clos reste `published`**. Sans ce champ, la clôture ne s'apprenait qu'en échouant sur `422 form_closed`.\n"},"created_at":{"type":"string","format":"date-time"}}},"Campaign":{"type":"object","description":"Une vague de liens. Les compteurs portent sur les **liens**, jamais sur les réponses : une réponse anonyme n'est rattachée à aucun lien, donc à aucune vague. `links_responded` est donc le numérateur de votre taux de réponse, sur les deux régimes.\n","required":["id","name","form_id","status","created_at","links_total","links_pending","links_responded","links_expired","links_opted_out"],"properties":{"id":{"type":"integer"},"name":{"type":"string","description":"Le `campaign_name` que vous avez fourni, ou « nom de la clé — nom du formulaire » pour la campagne par défaut d'une clé. Une campagne créée par l'API ne se renomme pas.\n"},"form_id":{"type":"integer"},"status":{"$ref":"#/components/schemas/CampaignStatus"},"created_at":{"type":"string","format":"date-time"},"links_total":{"type":"integer","minimum":0,"description":"Liens émis pour la campagne. Ne diminue jamais : les liens supprimés au terme de leur durée de conservation (24 mois après leur échéance) y restent comptés, alors que les décomptes par statut (`links_pending`, `links_responded`, `links_expired`, `links_opted_out`) ne portent que sur les liens conservés. Avant toute suppression, `links_total` est leur somme.\n"},"links_pending":{"type":"integer","minimum":0,"description":"En attente de réponse et non échus."},"links_responded":{"type":"integer","minimum":0},"links_expired":{"type":"integer","minimum":0,"description":"Échus sans réponse — comptés dès l'échéance, comme le `status` de chaque lien."},"links_opted_out":{"type":"integer","minimum":0,"description":"Le destinataire s'est opposé. Ne les relancez pas : un lien opposé reste compté ici même une fois échu, le motif prime sur l'échéance.\n"}}},"CampaignStatus":{"type":"string","enum":["processing","completed"],"description":"Le statut affiché à l'écran. `processing` : au moins un lien peut encore recevoir une réponse ; `completed` : plus aucun — tous répondus, échus, ou rendus inutilisables par « Terminer ». Calculé sur les liens à chaque lecture : une campagne se termine d'elle-même à la dernière réponse ou à l'échéance du dernier lien ouvert, et une campagne d'API repasse `processing` quand un appel lui ajoute des liens. Prévoyez un cas par défaut : de nouveaux statuts peuvent s'ajouter.\n"},"CampaignDetail":{"allOf":[{"$ref":"#/components/schemas/Campaign"},{"type":"object","required":["links"],"properties":{"links":{"type":"array","description":"Page courante des liens de la campagne, du plus récent au plus ancien.","items":{"$ref":"#/components/schemas/LinkStatus"}}}}]},"Token":{"type":"string","pattern":"^[A-Za-z0-9_-]{11}[A-Za-z0-9]$","description":"Token d'un lien de réponse — 11 caractères base64url aléatoires suivis d'un caractère alphanumérique, sans lien avec la référence."},"CreateLinksRequest":{"type":"object","additionalProperties":false,"description":"Exactement un des deux modes `contacts` ou `count`. Tout autre paramètre est refusé (`400 unknown_parameter`).","properties":{"campaign_name":{"type":"string","maxLength":120,"description":"Nom de la campagne qui reçoit les liens, affiché dans l'interface. Les appels d'une même\nclé sur un même formulaire portant exactement ce nom rejoignent la même campagne. Absent :\nla campagne par défaut de la clé sur ce formulaire, nommée « nom de la clé — nom du\nformulaire ». Aucun caractère de contrôle (U+0000 à U+001F, U+007F à U+009F, tabulation et\nretours à la ligne compris) : `400 bad_request`.\n"},"contacts":{"type":"array","minItems":1,"maxItems":10000,"description":"Un lien par entrée, dans l'ordre.","items":{"type":"object","required":["reference"],"additionalProperties":false,"properties":{"reference":{"type":"string","minLength":1,"maxLength":255,"description":"Votre identifiant (dossier, client…). Renvoyé dans la réponse, jamais conservé."}}}},"count":{"type":"integer","minimum":1,"maximum":10000,"description":"Nombre de liens à générer, sans référence."}},"oneOf":[{"required":["contacts"],"not":{"required":["count"]}},{"required":["count"],"not":{"required":["contacts"]}}]},"CreatedLink":{"type":"object","required":["reference","token","url","expires_at"],"properties":{"reference":{"type":["string","null"],"description":"La référence fournie, ou `null` en mode `count`."},"token":{"$ref":"#/components/schemas/Token"},"url":{"type":"string","format":"uri","description":"Le lien de réponse à distribuer, à usage unique."},"expires_at":{"type":"string","format":"date-time","description":"Date au-delà de laquelle ce lien ne répond plus : `min(création + 3 mois, date de fin du formulaire)`. Elle peut changer après coup — prolonger la date de fin d'un formulaire redate tous ses liens déjà distribués, et aucun appel ne vous en avertit. Relisez-la sur `GET /campaigns/{id}`.\n"}}},"CreateLinksResponse":{"type":"object","required":["data","meta"],"properties":{"data":{"type":"object","required":["created"],"properties":{"created":{"type":"array","items":{"$ref":"#/components/schemas/CreatedLink"}}}},"meta":{"type":"object","required":["total","campaign_id","campaign_name"],"properties":{"total":{"type":"integer","minimum":1},"campaign_id":{"type":"integer"},"campaign_name":{"type":"string"}}},"balance":{"$ref":"#/components/schemas/LinkBalance"}}},"LinkBalance":{"type":"object","description":"Solde de liens de l'organisation après cet appel. Présent à chaque réponse réussie à partir de la date de début du décompte ; absent avant. L'appel a réussi : les liens demandés sont créés.\n","required":["remaining","low"],"additionalProperties":false,"properties":{"remaining":{"type":"integer","description":"Liens restants après cette génération.","example":8500},"low":{"type":"boolean","description":"`true` quand le solde est de 10 000 liens ou moins : l'état « Solde bas » de l'interface.","example":true}}},"LinkStatusValue":{"type":"string","description":"`pending` : en attente de réponse ; `responded` : répondu ; `expired` : périmé ; `opted_out` : le destinataire s'est opposé. Sur un formulaire anonyme comme nominatif. Le statut est **dérivé de l'échéance** : un lien en attente dont `expires_at` est passé se rend `expired` tout de suite, sans attendre aucun traitement différé — il ne répond déjà plus. Un lien `opted_out`, `responded` ou `expired` garde son statut une fois échu : le motif prime sur le constat, et vous ne devez pas relancer quelqu'un qui s'est opposé.\n","enum":["pending","responded","expired","opted_out"]},"LinkStatus":{"type":"object","required":["token","status","created_at","expires_at","sent_at","completed_at"],"properties":{"token":{"$ref":"#/components/schemas/Token"},"status":{"$ref":"#/components/schemas/LinkStatusValue"},"created_at":{"type":"string","format":"date-time"},"expires_at":{"type":["string","null"],"format":"date-time"},"sent_at":{"type":["string","null"],"format":"date-time","description":"Date de la dernière livraison du lien par un canal géré par la plateforme ; `null` pour un lien auto-distribué, que vous remettez vous-même.\n"},"completed_at":{"type":["string","null"],"format":"date-time","description":"Date de la réponse. `null` tant qu'il n'y a pas de réponse, et toujours `null` sur un formulaire anonyme."}}},"IndicatorType":{"type":"string","enum":["csat_indicator","nps_indicator","enps_indicator","ces_indicator","star_rating_indicator","fcr_indicator","pmf_indicator"]},"Scope":{"type":["object","null"],"description":"Périmètre de la question (service, agence…), ou `null` si la question n'en a pas.","required":["id","name"],"properties":{"id":{"type":"integer"},"name":{"type":"string"}}},"Answer":{"type":"object","required":["form_field_id","label","indicator_type","scope","value","autre","verbatim","detail"],"properties":{"form_field_id":{"type":"integer","description":"Identifiant de la question (configuration, pas une réponse)."},"label":{"type":["string","null"],"description":"Le libellé de la question **tel qu'il a été affiché au répondant** :\nle marqueur `[périmètre]` du libellé stocké y est remplacé par le nom\ndu périmètre (`scope.name`), ou par `(périmètre)` si la question n'en\na pas. Toute question à indicateur contient ce marqueur.\n"},"indicator_type":{"oneOf":[{"$ref":"#/components/schemas/IndicatorType"},{"type":"null"}],"description":"Type d'indicateur, ou `null` pour une question libre."},"scope":{"$ref":"#/components/schemas/Scope"},"value":{"type":["integer","boolean","string","array","null"],"items":{"type":"string"},"description":"La valeur, typée selon la question :\n\n| Question | `value` |\n|---|---|\n| échelle, NPS (note) | entier |\n| Oui / Non (FCR ou question libre) | booléen |\n| texte libre | chaîne |\n| choix unique | chaîne : le libellé choisi (« Autre » si le répondant a choisi l'option Autre) |\n| choix multiple | tableau de chaînes : les libellés cochés (« Autre » y figure une seule fois) |\n| PMF | chaîne : la **clé** du choix, jamais son libellé |\n| sans réponse | `null` |\n\nLe PMF a ses quatre modalités figées, et l'API en rend la clé — stable,\nindépendante de la langue d'affichage. Les écrans et l'export CSV en\nrendent le libellé :\n\n| Clé | Libellé |\n|---|---|\n| `tres_decu` | Très déçu |\n| `plutot_decu` | Plutôt déçu |\n| `pas_decu` | Pas déçu |\n| `ne_utilise_plus` | Je ne l'utilise plus |\n"},"autre":{"type":["array","null"],"items":{"type":"string"},"description":"Les textes que le répondant a saisis sous l'option « Autre » d'une question à choix (cinq au maximum, un seul sauf sur un choix multiple qui autorise plusieurs réponses Autre). `null` si la question n'a pas d'option Autre ou si « Autre » n'a pas été choisi. Même traitement que les autres textes libres : masqué sur un formulaire anonyme tant qu'il n'est pas consultable, supprimé avec la réponse à la purge.\n"},"verbatim":{"type":["string","null"],"description":"Commentaire saisi sous une question à indicateur. `null` sur une question libre (son texte est déjà `value`).\n"},"detail":{"type":["string","null"],"description":"Seconde sous-réponse d'une question (motif FCR, par exemple)."}}},"Response":{"type":"object","required":["public_id","token","completed_at","answers"],"properties":{"public_id":{"type":"string","format":"uuid","description":"Seul identifiant de la réponse — opaque, sans lien avec un token."},"token":{"description":"Le lien par lequel cette réponse est arrivée, pour la rattacher à son destinataire : c'est le seul rattachement que l'API restitue, la `reference` que vous fournissez à la génération n'étant jamais conservée. `null` sur une réponse d'un formulaire anonyme — la plateforme n'y conserve aucun lien entre le token et la réponse. `null` également sur une réponse d'un formulaire nominatif **dont les données de contact ont été purgées** : 24 mois après l'échéance de son lien, la réponse est dissociée et conservée sous forme anonyme ; son contenu n'est jamais supprimé par cette purge. La dissociation n'a pas lieu pour un formulaire en examen, tant que dure l'examen.\n","oneOf":[{"$ref":"#/components/schemas/Token"},{"type":"null"}]},"completed_at":{"description":"Horodatage complet UTC (`date-time`) sur une réponse nominative ; date seule au jour local de l'organisation (`date`) sur une réponse dissociée de son destinataire — toute réponse d'un formulaire anonyme, où c'est sa date de fin, et toute réponse purgée d'un formulaire nominatif (voir `token`). La granularité se lit dans la valeur elle-même : les deux formats ont des motifs distincts.\n","oneOf":[{"type":"string","format":"date-time","pattern":"^\\d{4}-\\d{2}-\\d{2}T"},{"type":"string","format":"date","pattern":"^\\d{4}-\\d{2}-\\d{2}$"},{"type":"null"}]},"answers":{"type":"array","items":{"$ref":"#/components/schemas/Answer"}}}},"Aggregate":{"type":"object","description":"Un couple (indicateur, périmètre), jamais fusionné à travers les périmètres.","required":["indicator_type","scope","threshold","n","methodo_warning","insufficient_n","score","distribution"],"properties":{"indicator_type":{"$ref":"#/components/schemas/IndicatorType"},"scope":{"$ref":"#/components/schemas/Scope"},"threshold":{"type":["integer","null"],"minimum":5,"description":"Seuil *k* du formulaire ; `null` sur un formulaire nominatif."},"n":{"type":["integer","null"],"minimum":0,"description":"Nombre de réponses agrégées — un compteur, jamais une valeur individuelle. `null` quand la restitution est retenue (`masking` non `null`), comme la carte masquée de l'écran.\n"},"methodo_warning":{"type":"boolean","description":"Vrai si les questions agrégées n'ont pas la même formulation."},"insufficient_n":{"type":"boolean","description":"Plancher de fiabilité — vrai sous 5 réponses : `score` est `null`, l'entrée reste lisible. Présent en permanence.\n"},"score":{"type":["number","null"],"description":"Score de l'indicateur ; `null` quand la restitution est retenue (`masking`) ou sous le plancher de fiabilité (`insufficient_n`).\n"},"distribution":{"type":["object","null"],"description":"Répartition par valeur, clé = valeur (note, `true`/`false`, choix), valeur = effectif. Sur un PMF, les clés sont celles du choix (`tres_decu`, `plutot_decu`, `pas_decu`, `ne_utilise_plus`), comme `value` — jamais les libellés. `null` quand la restitution est retenue (`masking`).\n","additionalProperties":{"type":"integer","minimum":0}}}},"ResultsResponse":{"type":"object","required":["data","meta","aggregates_window","masking","aggregates"],"properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Response"}},"meta":{"$ref":"#/components/schemas/PaginationMeta"},"aggregates_window":{"type":"object","description":"Période sur laquelle `aggregates` est calculé — toujours publiée, pour qu'un chiffre ne soit jamais lu sur une période supposée.\nSur un formulaire nominatif : les bornes `from` / `to` reçues (`window: null`) ; à défaut, la fenêtre `window` demandée, `12` mois par défaut comme à l'écran (`from` est son premier jour au fuseau de l'organisation, `null` sur `\"all\"`, `to` la date du jour).\nSur un formulaire anonyme : la fenêtre de calcul de l'écran la plus étroite qui contient la période demandée, quelle que soit la façon dont elle a été demandée.\n","required":["window","from","to"],"properties":{"window":{"type":["string","null"],"enum":["3","6","12","all",null]},"from":{"type":["string","null"],"format":"date"},"to":{"type":["string","null"],"format":"date"}}},"masking":{"type":["object","null"],"description":"Pourquoi `data` et les agrégats sont retenus — dit une fois pour tout le formulaire. **Toujours présent**, `null` quand rien n'est retenu (formulaire nominatif, ou anonyme passé sa date de fin avec au moins *k* réponses). Ne publie ni compteur ni date de suppression.\n","required":["reason","ends_at"],"properties":{"reason":{"type":"string","enum":["before_end_date","below_anonymity_threshold","responses_purged"],"description":"`before_end_date` : la date de fin du formulaire anonyme n'est pas passée ; `below_anonymity_threshold` : le formulaire est clos avec moins de *k* réponses au total ; `responses_purged` : ces réponses ont été **supprimées**, elles ne reviendront pas (voir « Formulaires anonymes »). Prévoyez un cas par défaut : de nouveaux motifs peuvent s'ajouter.\n"},"ends_at":{"type":["string","null"],"format":"date-time","description":"Pour `before_end_date` : la date de fin du formulaire, à partir de laquelle revenir. `null` pour les deux autres motifs, et pour un formulaire anonyme qui n'a pas encore de date de fin — il ne peut alors recevoir aucun nouveau lien (`422 form_closed` à la génération), et rien n'en sera restitué tant qu'aucune date de fin n'est posée puis atteinte.\n"}}},"aggregates":{"type":"array","description":"Toujours présent — vide si le formulaire ne porte aucune question à indicateur.","items":{"$ref":"#/components/schemas/Aggregate"}}}}}}}