FR EN

Référence de l'API

L'API /v1 d'Agreely est le backend que les clients TypeScript, PHP et CLI encapsulent. Elle est sans état et de serveur à serveur. Cette page décrit chaque point d'accès : sa portée, son corps, sa réponse et ses refus. La référence /v1 interactive présente les mêmes schémas, rendus à partir de la spécification OpenAPI.

Authentification

Chaque appel utilise une clé d'API Bearer :

Authorization: Bearer agr_live_...

La clé seule identifie le locataire. Il n'y a ni session ni CSRF. Chaque clé porte une ou plusieurs portées (scopes), choisies à sa création dans l'application :

  • check (cochée par défaut) : la vérification (/v1/check, /v1/check/batch), la lecture du catalogue et des documents, un énoncé de conservation.
  • issue (cochée par défaut) : les demandes de consentement, la lecture du catalogue et des documents.
  • attest (cochée par défaut) : les consentements sur papier, les feuilles de signature, les liens de réclamation, le retour de la feuille signée d'un consentement verbal, le retrait et l'effacement d'un consentement consigné par l'entreprise.
  • attest_verbal (jamais cochée par défaut) : les consentements donnés par téléphone et leur historique.
  • relationship (cochée par défaut) : la fin d'une relation client et l'annulation d'une fin erronée.
  • registry (cochée par défaut) : l'identité d'un client au registre, sa posture de conservation, ses déclarations de disposition et ses retenues de conservation.
  • retention (cochée par défaut) : les règles de conservation, les cellules du catalogue, les purges et les passes déclarées.
  • inventory (cochée par défaut) : la déclaration des ensembles de dossiers d'un système hôte.
  • withdraw (jamais cochée par défaut) : le retrait consigné au nom de la personne, sur tout consentement qu'elle a donné.
  • holds (jamais cochée par défaut) : le flux des retenues de conservation en place.

Les trois portées jamais cochées par défaut doivent chacune être un choix délibéré de la personne qui crée la clé. Toute clé valide, quelle que soit sa portée, peut appeler GET /v1/whoami.

Une clé absente, invalide ou révoquée donne 401. Une clé valide dépourvue de la portée requise donne 403. Un espace de travail sans abonnement actif donne 402 sur toutes les routes, sauf la route de retrait (voir le retrait consigné).

Points d'accès

Identité et vérification

  • GET /v1/whoami (toute clé) : les portées de la clé et l'organisation qu'elle désigne.
  • POST /v1/check (portée check) : décision de consentement pour une cellule.
  • POST /v1/check/batch (portée check) : décisions pour plusieurs cellules en un appel (plafond de 500).

Catalogue et documents

  • GET /v1/catalog (portée check ou issue) : le catalogue actif, entier ou restreint à un document.
  • GET /v1/catalog/cells (portée retention) : toutes les cellules et la règle de conservation de chacune.
  • GET /v1/consent-documents (portée check ou issue) : les documents de consentement publiés.
  • GET /v1/consent-documents/{code} (portée check ou issue) : un document publié, avec sa divulgation complète.
  • GET /v1/consent-documents/versions/{documentVersionId}/pdf (portées check, issue, attest ou attest_verbal) : le document d'information d'une version, en PDF.

Demandes de consentement

  • POST /v1/consent-requests (portée issue) : émettre une demande de consentement.
  • GET /v1/consent-requests (portée issue) : lister les demandes, paginées par curseur.
  • GET /v1/consent-requests/{id} (portée issue) : une demande par son requestId.
  • POST /v1/consent-requests/{id}/cancel (portée issue) : annuler une demande en attente.

Consentements sur papier et par téléphone

  • POST /v1/manual-consents (portée attest) : consigner un consentement signé sur papier.
  • POST /v1/manual-consents/claim-links (portée attest) : émettre un lien de réclamation à usage unique.
  • POST /v1/manual-consents/{consentRef}/revoke (portée attest ou attest_verbal) : retirer un consentement consigné par l'entreprise.
  • POST /v1/manual-consents/{consentRef}/erase (portée attest) : effacer (destruction cryptographique) un consentement consigné par l'entreprise.
  • POST /v1/verbal-consents (portée attest_verbal) : consigner un consentement donné par téléphone.
  • GET /v1/verbal-consents/{consentId} (portée attest_verbal ou attest) : l'historique d'un consentement verbal.
  • POST /v1/verbal-consents/{consentId}/paper (portée attest) : consigner la feuille signée qui confirme un consentement verbal.
  • POST /v1/customers/{customerRef}/consent-sheets (portée attest) : produire la feuille de signature d'une version, avec son lien de réclamation.

Clients, retrait et relation

  • PUT /v1/customers/{customerRef} (portée registry) : consigner ou modifier l'identité d'un client au registre.
  • GET /v1/customers/{customerRef} (portée registry) : ce que le registre détient pour un client, en métadonnées.
  • POST /v1/customers/{customerRef}/consents/{consentRef}/withdrawal (portée withdraw) : consigner le retrait demandé par la personne.
  • POST /v1/customers/{customerRef}/relationship/end (portée relationship) : mettre fin à une relation client.
  • POST /v1/customers/{customerRef}/relationship/revert (portée relationship) : annuler une fin de relation erronée.

Conservation

  • GET /v1/customers/{customerRef}/retention (portée registry) : la posture de conservation d'un client, avec ses retenues.
  • POST /v1/customers/{customerRef}/retention/dispositions (portée registry) : déclarer ce que le système hôte a fait des renseignements.
  • POST /v1/customers/{customerRef}/retention/holds (portée registry) : placer une retenue de conservation.
  • POST /v1/customers/{customerRef}/retention/holds/{holdId}/release (portée registry) : lever une retenue de conservation.
  • GET /v1/retention/holds (portée holds) : le flux des retenues en place.
  • GET /v1/retention/rules (portée retention) : les règles de conservation décidées par l'organisation.
  • GET /v1/retention/rules/{ruleKey} (portée retention) : une règle et les derniers rapports de l'hôte.
  • POST /v1/retention/rules/{ruleKey}/purges (portée retention) : déclarer une purge.
  • POST /v1/retention/rules/{ruleKey}/sweeps (portée retention) : déclarer une passe qui n'a rien trouvé à purger.

Inventaire

  • PUT /v1/inventory/categories (portée inventory) : déclarer les ensembles de dossiers d'un système hôte.
  • GET /v1/inventory/categories (portée inventory ou retention) : les ensembles déclarés et ce que votre organisation a décidé pour chacun.
  • GET /v1/inventory/statements/{statementKey} (portées inventory, retention ou check) : un énoncé de conservation figé.

Le client est toujours désigné par votre propre référence : customerId dans le corps de /v1/check, des consentements et des liens de réclamation, customerRef dans le chemin des routes /v1/customers/... et dans le lot. Les deux désignent la même valeur, résolue au sein de l'entreprise de la clé. Une référence peut contenir une barre oblique (MAGASIN/0042) : dans un chemin, encodez-la en pourcentage (/v1/customers/MAGASIN%2F0042). Une référence . ou .. est refusée par toute route qui en crée une (422, raison invalid_customer_id) : aucun chemin ne peut la porter. Aucune route n'accepte ni ne renvoie un DID de citoyen.

Conventions communes

Le corps de la requête

Envoyez chaque champ dans un corps JSON (Content-Type: application/json). Les routes qui écrivent (demandes de consentement, consentements sur papier et par téléphone, liens de réclamation, feuilles de signature, retraits et toutes les routes /v1/customers/..., de conservation et d'inventaire) ne lisent que le corps : un champ placé dans la chaîne de requête y est ignoré, pour qu'il n'aboutisse jamais dans les journaux d'un serveur mandataire. Seuls les filtres documentés des routes GET (status, cursor, changedSince, pageToken, locale, documentCode, hostSystem) se passent dans la chaîne de requête. Un corps qui n'est pas un objet JSON en UTF-8 valide donne 400 avec la raison invalid_json.

L'enveloppe d'erreur

Chaque erreur (jamais un refus de consentement, qui est un 200) partage une seule forme :

{
  "error": {
    "code": "conflict",
    "message": "…",
    "reason": "predates_withdrawal",
    "field": "effectiveDate"
  }
}
  • code est la classe de l'erreur, liée au statut HTTP.
  • reason, quand elle est présente, est la raison machine stable du refus. Chaque refus des routes de consentement sur papier, de consentement verbal, de liens de réclamation, de feuilles de signature et de retrait en porte une.
  • field, quand il est présent, nomme le champ fautif, dans le vocabulaire de la route. Il accompagne tout refus qui porte sur un seul champ, en 422 comme en 409 : par exemple effectiveDate sur predates_withdrawal, validUntil sur renewal_ends_before_current, evidence.pdfSha256 sur invalid_pdf_hash, et items sur un consentement sur papier là où un consentement par téléphone, ou sa feuille signée, nomme answers. Un refus qui porte sur l'état de l'enregistrement n'en porte pas.
  • message est une phrase en anglais destinée à un humain. Elle peut être reformulée à tout moment.

Comparez reason, jamais message

Branchez votre code sur code et reason. Le texte de message n'est pas un contrat : un test sur ce texte cassera le jour où il sera reformulé. Une raison nouvelle peut s'ajouter; traitez une raison inconnue d'après son code. Les SDK exposent reason et field sur leur type d'erreur.

Les routes de conservation, de retenue, de registre et d'inventaire nomment leur refus directement dans code (par exemple already_released, identity_held, new_set_limit). Quatre raisons sont partagées par les routes qui en portent : missing_field et invalid_field (422, avec field), invalid_idempotency_key (422) et invalid_json (400).

  • 400 invalid_request : un corps illisible (invalid_json), ou un filtre status inconnu sur la liste des demandes.
  • 400 bad_request : un chemin ou un paramètre de chaîne de requête qui n'est pas en UTF-8 valide, refusé avant qu'aucune route ne s'exécute.
  • 401 unauthorized : clé absente, invalide ou révoquée.
  • 402 billing_inactive : l'espace de travail n'a pas d'abonnement actif. Toutes les routes, sauf le retrait.
  • 403 forbidden : clé valide, portée manquante.
  • 404 not_found : ressource introuvable. Une référence inconnue, malformée ou appartenant à une autre organisation reçoit la même réponse, octet pour octet.
  • 409 conflict et codes propres : l'état de l'enregistrement interdit l'acte; reason ou code dit lequel.
  • 413 body_too_large : le corps dépasse la taille admise par la route.
  • 422 invalid_request et codes propres : entrée invalide; field nomme le champ.
  • 429 rate_limited et plafonds : fenêtre de débit dépassée (avec Retry-After), ou plafond quotidien atteint (sans Retry-After).

Une erreur interne répond 500 avec le code internal_error. Le code unavailable est réservé, mais aucune route ne l'émet pour l'instant.

Limites de débit

Les limites sont par entreprise, en fenêtre fixe (120 requêtes par minute par défaut). Chaque route /v1 est limitée. Un 429 rate_limited porte un en-tête Retry-After (secondes, entier). Quelques actions ont en plus un plafond quotidien par entreprise, sur 24 heures glissantes, qui répond 429 sans Retry-After : ce n'est pas une file d'attente à patienter, c'est un garde-fou.

  • Consentements verbaux consignés : 200 (verbal_daily_cap, raison daily_cap).
  • Retraits consignés par /withdrawal : 50 (withdrawal_daily_cap, raison daily_cap).
  • Retenues placées par l'API : 1 000 (hold_budget_exhausted).
  • Levées de retenues que l'API n'a pas placées : 20 (hold_release_cap_reached).

L'exploitant d'Agreely peut ajuster ces plafonds pour un client dont le volume le justifie. Une seconde passe sous la même règle, depuis le même système hôte, en moins de 15 minutes donne 429 sweep_too_frequent, avec Retry-After.

Idempotence

Un en-tête Idempotency-Key (1 à 255 caractères ASCII imprimables, sinon 422 invalid_idempotency_key) rend une nouvelle tentative sûre :

Idempotency-Key: order-4471
  • POST /v1/consent-requests : une nouvelle tentative avec la même clé rejoue le 201 d'origine octet pour octet : ni double émission, ni double courriel.
  • POST /v1/manual-consents, POST /v1/verbal-consents, POST /v1/verbal-consents/{consentId}/paper, POST .../withdrawal, POST .../retention/holds, POST .../holds/{holdId}/release : la clé est liée à la route et au corps. Même clé et même corps : la première réponse est rejouée et rien n'est écrit. Même clé avec un autre corps : c'est une nouvelle requête. Les requêtes concurrentes portant la même clé sont traitées une à la fois.
  • POST .../consent-sheets : la clé est un verrou, pas un rejeu : la feuille et son lien ne sont jamais conservés, donc une nouvelle tentative répond 409 already_minted et ne produit rien.
  • POST /v1/retention/rules/{ruleKey}/purges et /sweeps : la clé est obligatoire (422 idempotency_key_required) et liée à la clé d'API qui déclare. Même clé et même déclaration : 200 avec la déclaration conservée. Même clé et autre déclaration : 422 idempotency_key_reused.

Les SDK attachent une clé unique par appel; passez la vôtre pour qu'une nouvelle tentative après une coupure réseau soit reconnue.

Clé révoquée et mise en cache

Une clé révoquée répond 401 dès l'appel suivant. Pour que votre système le remarque vite sans multiplier les appels :

  • Appelez GET /v1/whoami une fois par minute, depuis un seul planificateur, pas depuis chaque processus. C'est le signal le moins coûteux qu'une clé a été révoquée ou que l'abonnement est en défaut.
  • Sur un 401 ou un 402, videz tout cache tiré d'Agreely et refusez par défaut jusqu'au rétablissement. Seule exception : continuez de consigner les retraits, que la route de retrait accepte même pendant un défaut de paiement.
  • Ne mettez jamais en cache une réponse de /v1/check. Un retrait refuse dès l'appel suivant et n'atteint qu'un système qui vérifie de nouveau. validUntil est une borne supérieure, jamais une durée de cache.
  • Ne décidez jamais d'un consentement à partir d'un catalogue en cache. Le catalogue et les documents servent à construire un formulaire; seule la vérification autorise un usage.

L'identité de la clé (whoami)

GET /v1/whoami · toute clé valide

Confirme, côté serveur, les portées que porte réellement la clé présentée, et le minimum qu'il faut pour découvrir l'organisation plutôt que de la coder en dur : son nom, la loi qui la régit et l'adresse absolue de sa page publique de confidentialité.

{
  "scopes": ["check", "issue"],
  "company": {
    "name": "Acme inc.",
    "sector": "private",
    "statute": "P-39.1",
    "publicPolicyUrl": "https://app.agreely.ca/p/acme/privacy"
  }
}

sector vaut private ou public, statute vaut P-39.1 ou A-2.1. publicPolicyUrl est null quand cette page ne se résoudrait pas (aucune politique publiée et aucun responsable désigné). La réponse ne porte volontairement aucun identifiant d'entreprise, aucun DID, aucun nom ni préfixe de clé, ni rien sur la taille, le forfait, l'usage ou la facturation de l'organisation : une clé d'API est un justificatif au porteur, et tout ce qu'elle obtient fuit avec elle.

La vérification : un refus est un 200

POST /v1/check · portée check

{
  "customerId": "cust_8812",
  "category": "Numéro de téléphone",
  "purpose": "Facturation"
}

Une décision de consentement, autorisation ou refus, est toujours un 200. Le refus n'est pas une erreur.

{
  "decision": "allow",
  "status": "active",
  "consentRef": "0x3103…c354",
  "assurance": "company_documented",
  "tier": "verbal",
  "validUntil": "2027-10-09T03:59:59Z",
  "checkedAt": "2026-10-09T14:05:12Z"
}
{
  "decision": "deny",
  "status": "revoked",
  "consentRef": "0x0fc2…337c",
  "assurance": "company_attested",
  "tier": "manual",
  "validUntil": "2027-10-09T03:59:59Z",
  "revokedAt": "2026-10-08T20:08:52Z",
  "checkedAt": "2026-10-09T14:05:12Z"
}
{
  "decision": "allow",
  "status": "necessity",
  "basis": "necessary_for_service",
  "checkedAt": "2026-10-09T14:05:12Z"
}

Branchez toujours sur decision (allow ou deny). Le status dit pourquoi :

  • active (allow) : un consentement en vigueur couvre la cellule.
  • necessity (allow) : aucun consentement consigné, mais la cellule active du catalogue déclare un motif autre que le consentement que la loi de l'organisation permet. La réponse porte alors basis, et ni consentRef, ni assurance, ni dates : il n'y a aucune preuve signée. L'appel ne crée rien.
  • none (deny) : aucun consentement sur une cellule fondée sur le consentement, ou aucune cellule active ne correspond aux libellés.
  • revoked (deny) : le consentement a été retiré.
  • expired (deny) : la durée de vie du consentement est écoulée (P-39.1, art. 14 al. 3; A-2.1, art. 53.1 al. 3).
  • relationship_ended (deny) : l'entreprise a attesté que la relation est terminée (P-39.1, art. 23; A-2.1, art. 73). C'est un arrêt prospectif de l'usage; son enregistrement reste au statut actif (il n'a jamais été retiré).
  • requires_depersonalization (deny) : la cellule déclare un motif que la loi de l'organisation ne permet que pour des renseignements dépersonnalisés. La vérification porte sur une référence client nommée, donc la condition ne peut pas être remplie ici. Ce n'est pas « aucun consentement » : n'y répondez pas en recueillant un consentement.
  • basis_not_in_regime (deny) : la cellule déclare un motif que la loi de l'organisation ne connaît pas, typiquement une cellule restée dans le vocabulaire de l'autre secteur. Le correctif est une nouvelle déclaration au catalogue.

Deux valeurs anciennes méritent une note. sensitive_requires_consent n'est plus émis depuis le 28 septembre 2026 : une cellule déclarée sensible répond d'après sa base comme toute autre (sur consent, elle refuse none tant qu'il n'y a pas de consentement, qui doit être exprès; sur un motif autre que le consentement, elle autorise necessity). Et erased reste dans l'énumération de la spécification, mais aucune réponse ne le porte : l'effacement détruit l'enregistrement que lit la vérification, et la cellule répond ensuite comme s'il n'y en avait jamais eu (none sur une cellule fondée sur le consentement).

Les champs d'une réponse adossée à un consentement :

  • consentRef : la référence 0x de la cellule, qui sert au retrait et à l'effacement.
  • assurance : la force de la preuve, citizen_signed (signé par la personne avec sa clé d'accès), company_attested (signé sur papier, attesté par l'entreprise) ou company_documented (donné par téléphone, documenté par l'entreprise). Un client qui ne connaît que les deux premières valeurs doit traiter une valeur inconnue comme non acceptable.
  • tier : le même palier sous son nom de stockage, full, manual ou verbal. C'est votre système qui décide de ce que chaque palier débloque; par exemple, accepter verbal pour un usage et exiger manual pour un autre est votre choix de preuve, pas un seuil légal.
  • validUntil : la fin du consentement (instant ISO 8601 en UTC), présente sur chaque statut adossé à un consentement, y compris revoked, expired et relationship_ended. Elle bouge dès qu'un consentement est renouvelé : relisez-la à chaque vérification. null seulement pour un consentement ancien consigné sans fin.
  • revokedAt : présent seulement quand status vaut revoked, c'est l'instant à partir duquel Agreely tient le consentement pour retiré. Pour une case laissée vide sur une feuille signée, c'est la date de la signature; pour un retrait consigné par /withdrawal ou /revoke, c'est l'instant de la consignation, pas nécessairement celui où la personne l'a exprimé.

consentRef, assurance, tier, validUntil et revokedAt sont absents (pas null) quand aucun consentement n'est en cause : none, necessity, requires_depersonalization et basis_not_in_regime. Une ligne qu'un document donne à titre d'information, et que la personne a seulement reconnue, n'est pas un consentement : la vérification y répond d'après la base actuelle du catalogue, comme s'il n'y avait aucun enregistrement.

Les valeurs de basis dépendent du secteur. Une entreprise privée (P-39.1) reçoit contract, necessary_for_service, security_fraud, legal_obligation ou professional_contact. Un organisme public (A-2.1) reçoit attributions, programme, entente_collecte, compatible_use, manifest_benefit, law_application ou public_character. Lisez regime dans GET /v1/catalog avant de brancher sur basis.

Agreely consigne la base déclarée, il ne la valide pas

Une autorisation necessity repose sur la base légale que votre organisation a déclarée sur la cellule du catalogue. Agreely consigne cette déclaration et l'applique de façon cohérente; il ne se prononce pas sur son bien-fondé juridique. À noter : professional_contact est une exclusion, non un motif de nécessité : les renseignements liés à l'exercice d'une fonction au sein d'une entreprise échappent aux règles de collecte, d'utilisation et de communication (P-39.1, art. 1 al. 5). Il figure ici parce qu'il produit lui aussi une autorisation sans consentement. public_character joue le même rôle pour un organisme public (A-2.1, art. 55 al. 1). Les motifs de nécessité sont ceux de P-39.1 (art. 9 et 12) ou de A-2.1 (art. 64 et 65.1).

Envoyez les category et purpose bruts tels que lus par un humain; le serveur les résout. La résolution est insensible à la casse et aux espaces; un libellé français se résout avec ou sans accents, et un libellé anglais se résout avec ou sans accents uniquement si l'entreprise a divulgué un libellé anglais pour cette cellule. Un libellé ambigu ou non divulgué ne correspond à rien et refuse (none). Ne normalisez pas côté client.

La vérification ne lit que le statut d'un seul enregistrement d'application indexé et ne touche jamais la chaîne ni l'indexeur, de sorte qu'un retrait refuse dès l'appel suivant. Elle fonctionne en refus par défaut : il n'y a pas d'autorisation sans consentement positif ni base déclarée. Une panne d'Agreely doit elle aussi être traitée comme un refus; c'est ce que font les SDK.

Refus : 401, 402, 403, 422 invalid_request (customerId, category ou purpose manquant, avec field; ou l'un d'eux envoyé comme une liste, un objet ou un booléen, avec la raison invalid_field), 429.

La vérification par lot

POST /v1/check/batch · portée check

Évalue plusieurs cellules en un seul aller-retour, avec le même moteur de décision, la même règle de refus par défaut et le même journal d'accès par décision. Deux formes de corps sont acceptées. La forme plate :

{
  "items": [
    {
      "customerRef": "cust_8812",
      "category": "Numéro de téléphone",
      "purpose": "Facturation"
    }
  ]
}

Et la forme groupée, développée côté serveur en un produit client x champs :

{
  "checks": [
    {
      "customerRef": "cust_8812",
      "fields": [
        {
          "category": "Numéro de téléphone",
          "purpose": "Facturation"
        },
        {
          "category": "Courriel",
          "purpose": "Infolettre"
        }
      ]
    }
  ]
}

La réponse est un tableau decisions aligné sur les cellules soumises. Chaque décision porte les mêmes champs qu'un /v1/check unique, y compris tier, validUntil et revokedAt, plus les customerRef, category et purpose renvoyés en écho :

{
  "decisions": [
    {
      "customerRef": "cust_8812",
      "category": "Numéro de téléphone",
      "purpose": "Facturation",
      "decision": "allow",
      "status": "active",
      "consentRef": "0x9a2b…7081",
      "assurance": "citizen_signed",
      "tier": "full",
      "validUntil": "2031-01-01T04:59:59Z",
      "checkedAt": "2026-10-09T14:05:12Z"
    }
  ]
}

Un lot vide ou de plus de 500 cellules donne 422 (invalid_request : « Batch exceeds the 500-item cap. Split into smaller batches. ») : découpez en lots plus petits. Une référence ou un libellé envoyé comme une liste, un objet ou un booléen donne 422 avec la raison invalid_field. Le lot nomme le sujet customerRef, l'appel unique customerId; les SDK masquent cette différence.

Cas d'usage : protéger l'affichage d'une liste de clients

Afficher un tableau de N clients x M champs, c'est N x M utilisations de renseignements personnels. La loi limite l'utilisation aux fins de la collecte (P-39.1, art. 12; A-2.1, art. 65.1) et l'accès aux membres du personnel pour qui le renseignement est nécessaire à leurs fonctions (P-39.1, art. 20; A-2.1, art. 62). Agreely encadre chaque champ affiché d'une vérification et en consigne la décision, pour documenter cette limitation. Le lot regroupe la grille entière en un seul aller-retour tout en conservant une décision et une entrée de journal d'accès par cellule. Le regroupement est une optimisation de la même responsabilité par décision, jamais un affaiblissement.

Le catalogue et les documents

Lire le catalogue

GET /v1/catalog · portée check ou issue · filtre facultatif documentCode

Le catalogue (catégorie, finalité) actif de l'entreprise, en lecture seule. Passez documentCode pour restreindre la réponse aux cellules d'un document publié : la réponse ajoute alors document, dont le documentVersionId est celui qu'exigent les consentements sur papier et par téléphone.

{
  "regime": {
    "sector": "private",
    "statute": "P-39.1"
  },
  "document": {
    "code": "formulaire-accueil",
    "documentVersionId": "e4d41dc3-c8a5-44b1-96c5-be740c45cc6d"
  },
  "catalog": [
    {
      "id": "5b1e…",
      "category": "Photo",
      "purpose": "Publication",
      "description": null,
      "legalBasis": "consent",
      "sensitive": false
    }
  ]
}

legalBasis est le motif que l'entreprise déclare pour la cellule; consent signifie qu'elle exige un consentement. Un documentCode inconnu ou étranger donne le même 404.

Lire les cellules et leurs règles

GET /v1/catalog/cells · portée retention

Toutes les cellules, archivées comprises (avec leur status), chacune avec ses libellés anglais (categoryEn, purposeEn) et retentionRuleKey, la règle de conservation qui la gouverne. Un retentionRuleKey nul est une lacune du registre, signalée et jamais comblée : le système hôte s'abstient.

Lister les documents publiés

GET /v1/consent-documents · portée check ou issue

Chaque document publié, avec ses cellules :

{
  "documents": [
    {
      "code": "formulaire-accueil",
      "documentVersionId": "e4d41dc3-c8a5-44b1-96c5-be740c45cc6d",
      "version": "3",
      "name": "Formulaire d'accueil",
      "nameEn": "Intake form",
      "effectiveDate": "2026-09-01",
      "publishedAt": "2026-08-28T15:00:00Z",
      "items": [
        {
          "id": "5b1e…",
          "category": "Photo",
          "categoryEn": "Photo",
          "purpose": "Publication",
          "purposeEn": "Publication",
          "sensitive": false
        }
      ]
    }
  ]
}

Résolvez un document par son code, stable d'une version à l'autre; le documentVersionId désigne une seule version et change à chaque publication.

Lire un document publié

GET /v1/consent-documents/{code} · portée check ou issue

Le document publié et sa divulgation complète en français et en anglais (disclosure, responsable, automatedDecision, profiling, integrity), pour que votre formulaire affiche le texte publié plutôt qu'une paraphrase. Aucune langue ne se rabat sur l'autre. Un code inconnu ou étranger donne le même 404.

Télécharger le document d'information d'une version

GET /v1/consent-documents/versions/{documentVersionId}/pdf?locale=fr · portée check, issue, attest ou attest_verbal

Le document d'information d'une version, en PDF (application/pdf), à imprimer et à remettre à la personne, avec la feuille de signature ou seul. Il se nomme lui-même document d'information, reproduit le texte de cette version et son responsable, et précise qu'il ne contient aucune demande de consentement.

  • La version est désignée par son identifiant, jamais par son code : c'est le texte de la version consignée que la personne doit recevoir. Une version remplacée depuis est encore servie et dit qu'elle a été remplacée.
  • locale vaut fr (par défaut) ou en. L'anglais est refusé (422 english_text_missing) quand une section n'a pas de texte anglais, plutôt que d'imprimer un document incomplet.
  • Une version brouillon, inconnue, étrangère ou un identifiant malformé reçoivent le même 404.
  • La réponse est Cache-Control: no-store. Télécharger le PDF ne consigne rien : ce n'est pas une preuve que quelqu'un a été informé.

Les demandes de consentement (issue)

Émettre une demande

POST /v1/consent-requests · portée issue · honore Idempotency-Key

{
  "customerId": "cust_8812",
  "recipientEmail": "[email protected]",
  "validUntil": "2027-10-09",
  "documentCode": "formulaire-accueil"
}

Exactement un de consentDocumentId (l'identifiant de la version publiée) ou documentCode désigne le document sous lequel la demande est émise. Les cellules demandées découlent du document; un champ items est refusé (422). L'offre est signée côté serveur et le courriel est envoyé une fois la demande enregistrée. Réponse 201 :

{
  "requestId": "0x4b08…1234",
  "status": "pending",
  "deepLink": "https://…",
  "emailDelivered": true,
  "items": [
    {
      "category": "Photo",
      "purpose": "Publication"
    }
  ],
  "document": {
    "code": "formulaire-accueil",
    "name": "Formulaire d'accueil",
    "version": "3"
  }
}

Lister les demandes

GET /v1/consent-requests · portée issue

Liste uniquement les demandes de l'entreprise de la clé, plus récentes d'abord, avec nextCursor (null à la fin). Quatre paramètres, tous facultatifs :

Paramètre Rôle
status pending, approved, asks_declined, refused, expired ou revoked_before_action. Un statut inconnu donne 400.
customerId Votre propre référence client. Une référence qui n'est pas la vôtre ne correspond à rien.
cursor Le requestId après lequel paginer (pagination par clé, jamais par décalage).
limit Taille de page, ramenée dans [1, 100], 50 par défaut. Hors bornes, la valeur est ramenée dans l'intervalle; non numérique, elle retombe sur le défaut.

asks_declined désigne une demande à laquelle la personne a répondu en refusant chaque demande de consentement, en confirmant seulement avoir reçu les lignes données à titre d'information : aucun consentement n'a été obtenu, et cette demande n'apparaît jamais comme approved.

Lire une demande

GET /v1/consent-requests/{id} · portée issue

Une demande par son requestId, avec son statut effectif (une demande en attente dont l'échéance est passée se lit expired). Ses instants, validUntil, expiresAt, createdAt et settledAt (null tant que la demande n'est pas réglée), sont en RFC 3339, en UTC, comme dans la liste. Un identifiant inconnu ou étranger donne 404.

Annuler une demande

POST /v1/consent-requests/{id}/cancel · portée issue

Annule une demande en attente : elle passe à revoked_before_action, et le lien du citoyen ne mène plus à une approbation. L'appel est idempotent : une demande déjà terminale renvoie son statut courant avec cancelled: false.

{
  "requestId": "0x4b08…1234",
  "status": "revoked_before_action",
  "cancelled": true
}

Un requestId inconnu, étranger ou malformé donne 404 (jamais une écriture entre locataires).

Les consentements sur papier (attest)

Un consentement signé à la main sur une feuille, puis consigné par l'organisation. Sa réponse et sa vérification disent toujours assurance: "company_attested" et tier: "manual" : Agreely consigne que l'organisation atteste détenir une feuille signée dont l'empreinte correspond, il ne prouve pas qui l'a signée. Le guide Enregistrer un consentement hors ligne en décrit le parcours complet.

Consigner un consentement sur papier

POST /v1/manual-consents · portée attest · honore Idempotency-Key

{
  "customerId": "cust_8812",
  "documentVersionId": "e4d41dc3-c8a5-44b1-96c5-be740c45cc6d",
  "effectiveDate": "2026-10-08",
  "validUntil": "2027-10-31",
  "items": [ { "category": "Photo", "purpose": "Publication" } ],
  "evidence": { "pdfSha256": "0x9f2c…0d2e" },
  "sensitiveExpressAttested": false,
  "versionAttested": false
}
  • items nomme les demandes de consentement cochées sur la feuille (paires {category, purpose} ou identifiants de catalogue). Il peut être vide si chaque demande a reçu un « non ». Le serveur ajoute lui-même chaque ligne que le document donne à titre d'information, comme une reconnaissance et jamais comme un consentement.
  • effectiveDate est la date de la signature : une date AAAA-MM-JJ ou un instant RFC 3339 avec décalage horaire. Un instant dans le futur, même d'une seconde, est refusé (effective_date_in_future).
  • evidence.pdfSha256 est l'empreinte SHA-256 (0x + 64 hex) de la feuille signée, calculée chez vous. evidence.pdf (base64) est facultatif, pour une mise sous séquestre; il doit correspondre à l'empreinte.
  • sensitiveExpressAttested doit valoir le booléen JSON true quand une finalité cochée est déclarée sensible et repose sur le consentement : l'organisation atteste que le consentement a été donné expressément. Agreely consigne cette attestation et la clé qui l'a envoyée; il ne la vérifie pas.
  • versionAttested doit valoir true quand la feuille a été signée avant la publication de la version choisie : l'organisation atteste que la feuille signée demandait le consentement pour les mêmes finalités, avec la même information.

Réponse 201 :

{
  "consentId": "6f96…64ff",
  "merkleRoot": "0x…",
  "consentRefs": [
    "0x…",
    "0x…"
  ],
  "assurance": "company_attested",
  "anchored": false,
  "acknowledged": [
    {
      "category": "Adresse",
      "purpose": "Livraison",
      "consentRef": "0x…"
    }
  ],
  "asksDeclined": false
}

anchored est faux au moment de l'enregistrement; l'ancrage suit de façon asynchrone. asksDeclined vaut true quand aucune demande n'a été acceptée.

Une nouvelle feuille signée sur un consentement papier ou verbal encore en vigueur pour la même finalité est acceptée, et la vérification passe à la nouvelle; la précédente reste consignée telle quelle. Les refus d'état :

  • 409 stronger_consent_active : un consentement signé par clé d'accès est en vigueur pour une de ces finalités; il n'est jamais remplacé par un consentement sur papier.
  • 409 renewal_ends_before_current : la nouvelle feuille prendrait fin avant le consentement en vigueur qu'elle remplace. Agreely traite un consentement plus court comme un retrait : consignez plutôt un retrait.
  • 409 predates_withdrawal : la signature est antérieure à un retrait consigné pour la même finalité; elle ne le rouvre jamais (field : effectiveDate).
  • 409 relationship_ended : la relation avec ce client est terminée : elle n'accepte aucun nouveau consentement.
  • 409 verbal_awaits_paper : un consentement verbal de ce document attend sa feuille signée pour ce client : consignez cette feuille comme sa confirmation (/v1/verbal-consents/{consentId}/paper).

Refus d'entrée (422) : missing_field, invalid_field, invalid_customer_id, invalid_document, document_placeholder, notice_only (le document ne demande aucun consentement), no_items, invalid_items, invalid_pdf_hash, pdf_hash_mismatch, pdf_not_pdf, pdf_empty, invalid_effective_date, effective_date_in_future, invalid_valid_until, valid_until_in_past, valid_until_too_far (au-delà de 10 ans, une règle de produit d'Agreely), version_attestation_required, sensitive_express_required, capacity_declaration_required, capacity_verification_invalid, no_verified_domain.

Produire une feuille de signature

POST /v1/customers/{customerRef}/consent-sheets · portée attest

La feuille de signature d'une version publiée, pour un client que l'organisation connaît, en PDF à imprimer, avec le lien de réclamation que la personne pourra utiliser plus tard. C'est la même feuille que celle du parcours papier de l'application : une paire Oui/Non par demande de consentement, les finalités d'information sans case, la signature, et une référence imprimée sous la signature. Elle ne nomme jamais la personne.

{
  "documentVersionId": "e4d41dc3-c8a5-44b1-96c5-be740c45cc6d",
  "locale": "fr"
}

Réponse 201, Cache-Control: no-store :

{
  "signatureSheet": {
    "documentVersionId": "e4d41dc3-…",
    "locale": "fr",
    "contentType": "application/pdf",
    "filename": "agreely-formulaire-consentement-….pdf",
    "pdf": "<base64>"
  },
  "printedReference": "ABCD-EFGH",
  "claim": {
    "claimUrl": "https://…",
    "token": "…",
    "expiresAt": "2026-12-08T14:05:12Z"
  }
}

Imprimer, c'est émettre. La référence imprimée est le second facteur du lien de réclamation émis dans le même appel, et seule son empreinte est conservée : elle et le token sont renvoyés une seule fois, dans cette réponse. Une nouvelle feuille retire tout lien encore actif pour ce client, si bien que la référence d'une feuille précédente cesse de fonctionner. Une nouvelle tentative avec la même Idempotency-Key répond 409 already_minted et ne produit rien.

Deux règles à respecter avec une feuille de signature

N'envoyez jamais le lien de réclamation dans la même enveloppe que la feuille, ni dans le même message. La référence imprimée ne protège qu'un lien qui voyage séparément : réunis sur un même papier, ou dans un même envoi, les deux facteurs n'en font plus qu'un. Le PDF de la feuille porte la référence, jamais le lien.

N'envoyez jamais l'empreinte de la feuille vierge comme evidence.pdfSha256. La preuve est la feuille signée, numérisée au retour; c'est son empreinte que demande POST /v1/manual-consents. C'est pourquoi cette réponse ne porte volontairement aucune empreinte.

  • 404 unknown_customer (code not_found) : l'organisation ne détient aucun dossier pour ce client (la même réponse que pour les liens de réclamation). Rien n'est écrit.
  • 409 relationship_ended (code conflict) : la relation est terminée : aucun lien n'est émis.
  • 409 already_minted (code et raison) : cette Idempotency-Key a déjà produit une feuille; envoyez une nouvelle clé pour en imprimer une autre.
  • 413 body_too_large (code et raison) : le corps dépasse 4 096 octets.
  • 422 unknown_field (code et raison) : un membre que la requête ne définit pas.
  • 422 invalid_field (code invalid_request) : locale n'est ni fr ni en.
  • 422 invalid_document (code invalid_request) : la version n'est pas la version publiée d'un document de l'organisation.
  • 422 notice_only (code no_consent_ask) : aucune finalité de cette version n'est une demande de consentement : il n'y a rien à signer.
  • 422 claim_link_already_live (code invalid_request) : un lien a été émis au même instant pour le même client.

Émettre un lien de réclamation

POST /v1/manual-consents/claim-links · portée attest

Émet un lien à usage unique que la personne utilise pour rattacher ses consentements hors ligne à son identifiant décentralisé de citoyen. Corps { "customerId": "cust_8812", "reference": "order-4471" } (reference facultatif); réponse 201 { "claimUrl", "token", "expiresAt" }. Émettre un lien retire tout lien encore actif pour ce client.

Refus : 404 unknown_customer (aucun dossier pour ce client, rien n'est écrit), 409 relationship_ended, 422 missing_field (sans customerId) ou claim_link_already_live.

Retirer un consentement consigné par l'entreprise

POST /v1/manual-consents/{consentRef}/revoke · portée attest ou attest_verbal

Le retrait d'un consentement que l'organisation a consigné, sur papier ou par téléphone. Une clé qui ne porte que attest_verbal n'atteint que les consentements verbaux. Corps facultatif { "reason": "…" } (1 000 caractères au plus, sans caractère de contrôle). Réponse 200 :

{
  "consentRef": "0x0fc2…337c",
  "revoked": true,
  "alreadyRevoked": false,
  "gate": "denied"
}

gate dit ce que /v1/check répond désormais pour cette finalité : denied (ce consentement portait la décision, qui refuse maintenant), superseded (un consentement plus récent pour la même finalité portait déjà la décision et n'est pas touché) ou unchanged (répétition idempotente). Un retrait s'attache à la finalité, jamais à un seul enregistrement : il emporte au même instant chaque autre consentement de la même finalité encore en cours pour cette personne et lié à celui-ci (un renouvellement et la feuille qu'il renouvelle, un consentement verbal et la feuille qui l'a confirmé).

  • 404 unknown_consent : référence absente, malformée, étrangère, ou hors de la portée de la clé : la même réponse.
  • 409 superseded_by_paper : ce consentement verbal a été confirmé par sa feuille signée : retirez la consentRef du consentement sur papier qui l'a confirmé (GET /v1/verbal-consents/{consentId} la donne).
  • 409 citizen_consent_at_gate : la personne a signé la même finalité en ligne, avec sa clé d'accès, après ce consentement : cette route ne met jamais fin à un consentement que la personne a signé. Consignez son retrait par la route de retrait, qui retire les deux au même instant. Rien n'est écrit.
  • 422 invalid_field : reason trop long ou invalide.

Effacer un consentement consigné par l'entreprise

POST /v1/manual-consents/{consentRef}/erase · portée attest seulement

Procède à l'effacement (destruction cryptographique) d'une cellule consignée par l'entreprise : le sel de la cellule, l'enregistrement d'application, le reçu et tout PDF sous séquestre sont détruits; l'engagement est conservé dans l'arbre de Merkle. Un effacement ne se défait pas, d'où la portée attest seule. Corps facultatif { "reason": "…" }. Réponse 200 :

{
  "consentRef": "0x0fc2…337c",
  "erased": true,
  "alreadyErased": false,
  "gate": "denied"
}

Effacer la cellule papier qui a confirmé un consentement verbal efface aussi la cellule verbale. Effacer un consentement encore en vigueur retire (sans les effacer) les autres consentements de la même finalité encore en cours, qui gardent leur preuve. Les refus sont ceux du retrait : 404 unknown_consent, 409 superseded_by_paper et 409 citizen_consent_at_gate (consignez d'abord le retrait de la personne, puis effacez). L'effacement ne consulte pas les retenues de conservation : vérifiez qu'aucune demande d'accès ou de rectification n'est en cours.

Les consentements par téléphone (attest_verbal)

Un consentement donné par téléphone et documenté par l'organisation. C'est le plus faible des trois paliers (full > manual > verbal) : il n'y a aucun document, la preuve est la parole de l'organisation. La réponse et la vérification le nomment tier: "verbal" et assurance: "company_documented", jamais « signé ». La portée attest_verbal est distincte et n'est jamais cochée par défaut.

Consigner un consentement verbal

POST /v1/verbal-consents · portée attest_verbal · honore Idempotency-Key

{
  "customerId": "client-7",
  "documentVersionId": "e4d41dc3-c8a5-44b1-96c5-be740c45cc6d",
  "answers": [
    { "category": "Photo", "purpose": "Publication", "answer": "yes" },
    { "category": "Adresse", "purpose": "Livraison de repas", "answer": "no" }
  ],
  "obtainedAt": "2026-10-08T14:05:00-04:00",
  "obtainedBy": "Luc Gagnon",
  "scriptVersion": "accueil-v3",
  "respondent": { "consentedBy": "self" },
  "validUntil": "2027-10-31",
  "paperExpected": true
}
  • Chaque demande posée reçoit une réponse explicite, "yes" ou "no". Seuls les « oui » deviennent des consentements; chaque « non » est conservé dans le reçu signé. Une réponse sur une ligne donnée à titre d'information est refusée : le serveur consigne ces lignes comme reconnues à la lecture du script.
  • documentVersionId lie le consentement à la version exacte du document lue au téléphone. scriptVersion est le libellé que votre organisation donne au script lu (lettres, chiffres, ., _, -, 64 caractères au plus). Le script à lire est offert dans l'application Web d'Agreely, à la page Consentement verbal de la fiche du client.
  • obtainedAt est l'instant de l'appel (la date du consentement), RFC 3339 avec décalage horaire, au plus 7 jours dans le passé et jamais dans le futur : un appel daté une seconde après l'horloge d'Agreely est refusé (obtained_at_in_future). Le moment de la consignation est conservé à côté.
  • obtainedBy nomme la personne qui a pris l'appel; il est chiffré et reste hors du reçu signé.
  • respondent.consentedBy vaut self, self_with_assistant ou representative. Pour un représentant, representativeCapacity est requis. Pour un mineur de moins de 14 ans (isMinor: true), le représentant doit être le titulaire de l'autorité parentale ou le tuteur, nommé dans respondent.name.
  • sensitiveExpressAttested: true est requis quand un « oui » porte sur une finalité sensible fondée sur le consentement.
  • paperExpected dit que l'organisation attend la feuille signée en retour. C'est un attribut, pas un palier : si la feuille ne revient jamais, le consentement reste verbal, et Agreely ne le fait pas expirer pour autant.

Réponse 201 :

{
  "consentId": "0a4f…",
  "merkleRoot": "0x…",
  "consentRefs": [
    "0x…"
  ],
  "tier": "verbal",
  "assurance": "company_documented",
  "anchored": false,
  "paperExpected": true,
  "acknowledged": [],
  "asksDeclined": false
}

Une finalité déjà couverte par un consentement plus fort en vigueur (sur papier ou par clé d'accès) n'est jamais remplacée par un consentement verbal, sauf dans la fenêtre de renouvellement décrite ci-dessous.

  • 409 stronger_consent_active (code conflict) : un consentement plus fort est en vigueur pour une de ces finalités; laissez-les de côté. Rien n'est consigné.
  • 409 all_covered (code conflict) : chaque demande du document est déjà couverte par un consentement plus fort.
  • 409 renewal_ends_before_current (code conflict) : le nouveau consentement prendrait fin avant celui qu'il prend en relais.
  • 409 renewal_predates_current (code conflict) : le renouvellement est daté avant la signature de la feuille qu'il renouvelle.
  • 409 predates_withdrawal (code conflict) : l'appel est daté avant un retrait consigné pour la même finalité (à la même seconde, le retrait l'emporte).
  • 409 relationship_ended (code conflict) : la relation avec ce client est terminée.
  • 429 daily_cap (code verbal_daily_cap) : plafond quotidien de consentements verbaux atteint.

Refus d'entrée (422) : missing_field, invalid_answers, duplicate_answer, informed_line_answered, nothing_consented, invalid_obtained_at, obtained_at_in_future, invalid_obtained_by, invalid_script_version, invalid_respondent, minor_needs_guardian, invalid_valid_until, valid_until_too_far, sensitive_express_required, et les raisons communes au papier (invalid_customer_id, invalid_document, document_placeholder, notice_only, no_items, invalid_items, no_verified_domain).

Renouveler par téléphone un consentement sur papier

Un consentement signé sur papier dont la fin tombe dans les 30 jours civils (au calendrier de l'organisation) peut être renouvelé par téléphone. Ces 30 jours sont une règle de produit d'Agreely, pas un délai prévu par la loi. La requête est un POST /v1/verbal-consents ordinaire; un « oui » pour cette finalité est accepté :

  • c'est un nouveau consentement, qui renouvelle la feuille sans jamais la réécrire;
  • /v1/check répond aussitôt tier: "verbal" avec le nouveau validUntil, jusqu'au retour de la nouvelle feuille signée (POST .../paper), qui le fait passer à manual avec ce même validUntil. Un système qui exige manual voit donc verbal entre l'appel et la feuille;
  • son validUntil ne peut pas être antérieur à celui de la feuille (renewal_ends_before_current), et l'appel ne peut pas être daté avant la signature de la feuille (renewal_predates_current);
  • un « non » au renouvellement ne retire rien : la feuille reste en vigueur jusqu'à sa propre fin;
  • un retrait fait par n'importe quelle référence de la chaîne retire aussi le consentement en cours : retirer le renouvellement retire la feuille, et inversement.

Hors de la fenêtre, la requête est refusée comme d'habitude (stronger_consent_active ou all_covered). Une feuille retirée ou échue ne bloque rien. Un consentement signé par clé d'accès n'est jamais renouvelé par téléphone, même dans ses derniers jours : renouvelez-le par une nouvelle demande en ligne.

Consigner la feuille signée d'un consentement verbal

POST /v1/verbal-consents/{consentId}/paper · portée attest · honore Idempotency-Key

La feuille signée est revenue. Il faut attest (et non attest_verbal), parce que la feuille produit un consentement attesté par l'entreprise.

{
  "signedAt": "2026-10-20T10:30:00-04:00",
  "answers": [
    {
      "category": "Photo",
      "purpose": "Publication",
      "answer": "yes"
    }
  ],
  "evidence": {
    "pdfSha256": "0x…"
  }
}

Le consentement verbal n'est jamais réécrit : chaque finalité cochée sur la feuille passe dans un nouveau consentement manual, et la vérification le suit. Les deux dates restent distinctes : le consentement date de l'appel, la signature est sa date de preuve. Une finalité laissée sans « oui » sur la feuille est consignée comme un retrait daté de la signature. La feuille doit répondre exactement aux finalités encore consenties par téléphone; une finalité consentie pour la première fois sur papier est un nouveau consentement (POST /v1/manual-consents). Réponse 201 :

{
  "verbalConsentId": "0a4f…",
  "manualConsentId": "7c21…",
  "merkleRoot": "0x…",
  "tier": "manual",
  "confirmed": [
    {
      "category": "Photo",
      "purpose": "Publication",
      "verbalConsentRef": "0x…",
      "consentRef": "0x…"
    }
  ],
  "withdrawn": []
}

Refus : 404 not_found; 409 already_confirmed (une seule feuille par consentement verbal), lapsed (le consentement verbal a atteint sa fin) ou nothing_to_confirm; 422 invalid_signed_at, signed_at_in_future, paper_answer_missing, paper_purpose_not_consented_by_phone, ainsi que invalid_pdf_hash et pdf_hash_mismatch.

Lire l'historique d'un consentement verbal

GET /v1/verbal-consents/{consentId} · portée attest_verbal ou attest

Ce qui a été dit au téléphone, ce que la feuille a dit, et ce qui est en vigueur aujourd'hui. Le consentement verbal garde tier: "verbal" pour toujours; state vaut awaiting_paper, verbal_only ou paper_received, et chaque élément de purposes donne la réponse (yes, no ou informed), son statut et, le cas échéant, la consentRef papier qui le confirme. Un identifiant absent, étranger, malformé ou non verbal reçoit le même 404 (raison not_found).

Le retrait consigné au nom de la personne (withdraw)

POST /v1/customers/{customerRef}/consents/{consentRef}/withdrawal · portée withdraw · honore Idempotency-Key

L'organisation consigne, au nom de la personne, le retrait qu'elle lui a demandé par n'importe quel canal, sur tout consentement qu'elle détient pour elle : sur papier, par téléphone, ou signé en ligne avec sa clé d'accès. C'est le jumeau /v1 de l'action « Consigner un retrait » de la fiche client : /v1/check refuse aussitôt, la fin du consentement est ancrée, et l'acte est consigné comme une attestation de l'organisation, jamais comme la signature de la personne. La demande peut venir de la personne ou de son représentant (par exemple le titulaire de l'autorité parentale ou le tuteur d'un mineur de moins de 14 ans).

{
  "channel": "phone",
  "operator": "intervenant-0042",
  "requestedAt": "2026-10-08T09:15:00-04:00",
  "reason": "Appel du 8 octobre : ne souhaite plus recevoir l'infolettre."
}
  • channel (requis) : phone, email, mail, in_person ou other.
  • operator (requis) : l'identifiant opaque du membre de votre personnel qui a reçu la demande (1 à 64 lettres, chiffres, ., _, : ou -). Jamais un nom, jamais une adresse courriel. Il est consigné dans l'attestation chiffrée et n'est jamais renvoyé.
  • requestedAt (facultatif) : quand la personne l'a demandé, tel que déclaré, RFC 3339 avec décalage horaire, pas avant le consentement retiré ni dans le futur. Il est consigné à côté du retrait sans déplacer l'instant où Agreely ferme la vérification. Recommandation : cessez l'usage dès la demande de la personne et consignez le retrait sans délai.
  • reason (facultatif) : 1 000 caractères au plus, chiffré, détruit à l'effacement, jamais sur la chaîne, jamais renvoyé.

Réponse 200 :

{
  "consentRef": "0x9f2c…0d2e",
  "withdrawn": true,
  "alreadyWithdrawn": false,
  "recordedOnBehalf": true,
  "assurance": "company_attested",
  "gate": "denied",
  "alsoWithdrawn": [
    "0x…"
  ]
}
  • recordedOnBehalf: true et assurance: "company_attested" toujours : Agreely consigne que l'organisation dit avoir reçu et traité une demande; il n'en a pas été témoin. La réponse est la même quel que soit le palier du consentement, si bien qu'elle ne dit jamais si la personne détient un compte citoyen.
  • gate vaut denied (plus rien ne porte la décision), superseded (un autre consentement de la même finalité, signé en ligne après le requestedAt déclaré, la porte encore et n'est pas touché) ou unchanged (répétition).
  • alsoWithdrawn liste les autres consentements de la même finalité retirés au même instant. Un retrait s'attache à la finalité : retirer par n'importe quelle référence de la chaîne (un renouvellement et la feuille qu'il renouvelle, un consentement verbal et la feuille qui l'a confirmé) retire le consentement en cours. Quand la personne a signé la même finalité en ligne au plus tard au requestedAt déclaré, ce consentement est retiré lui aussi.

Le retrait est accepté après la fin de la relation (le droit de retirer survit à la relation) et pendant un défaut de paiement : c'est la seule route /v1 qui ne répond jamais 402, parce que donner suite à un retrait est une obligation, pas un service payant. Une répétition répond 200 avec alreadyWithdrawn: true, gate: "unchanged" et alsoWithdrawn: [], et n'écrit rien.

  • 404 unknown_consent (code not_found) : référence de consentement absente, malformée ou étrangère, référence client inconnue, ou consentement d'un autre client de la même organisation : la même réponse, octet pour octet.
  • 409 consent_lapsed (code conflict) : le consentement a atteint sa propre fin : il n'y a plus rien à retirer. Cela ne veut jamais dire que rien n'est en vigueur pour la finalité; relisez /v1/check.
  • 422 not_revocable (code invalid_request) : la cellule n'a jamais été une demande de consentement (elle repose sur un autre motif). Le message cite la disposition de la loi qui régit votre organisation.
  • 422 invalid_channel, invalid_operator, requested_at_invalid, requested_at_in_future, requested_at_before_grant, invalid_reason (code invalid_request) : champ invalide.
  • 429 daily_cap (code withdrawal_daily_cap) : plafond de 50 retraits par 24 heures glissantes atteint, sans Retry-After. Consignez les retraits suivants depuis la fiche client dans Agreely.

Une répétition n'est jamais refusée par le plafond, et la route /v1/manual-consents/{consentRef}/revoke n'entame pas ce budget. Au premier refus de la journée, le propriétaire de l'espace de travail reçoit un courriel (des comptes et des noms de clés seulement), et il reçoit chaque jour un relevé, par clé, de ce que les clés de l'organisation ont consigné.

Le registre des clients (registry)

La portée registry n'offre aucune route de liste : chaque route est adressée par une seule référence client que vous connaissez déjà. Aucune lecture en masse n'est donc possible, par construction.

Consigner l'identité d'un client

PUT /v1/customers/{customerRef} · portée registry

Consigne ou modifie l'identité que le registre détient pour un client tenu sans consentement, sur un motif documenté. C'est une fusion, jamais un remplacement : un champ absent reste tel quel, un champ à null ou "" est effacé, un champ avec une valeur est écrit.

{
  "displayName": "Léa Tremblay",
  "email": "[email protected]",
  "legalBasis": "contract",
  "basisNote": "Contrat de service no 4471",
  "noticeLocale": "fr"
}

legalBasis doit appartenir au vocabulaire du secteur de l'organisation, et ne peut pas valoir consent (un client tenu sur consentement relève du parcours de consentement). Réponse 201 si l'identité n'existait pas, 200 si elle a été fusionnée; le corps est celui du GET ci-dessous.

Refus : 422 invalid_request avec field (référence de 1 à 200 caractères, displayName de 200 caractères au plus, email valide, basisNote de 2 000 caractères au plus, legalBasis hors vocabulaire, noticeLocale autre que fr ou en); 409 identity_erased (une déclaration de destruction ou d'anonymisation a retiré l'identité : un nom, un courriel, une note ou une langue sont refusés, mais un champ peut encore être effacé); 409 identity_held (une retenue sur tous les renseignements conserve l'identité : aucun changement à ces quatre champs, effacement compris).

Lire ce que le registre détient

GET /v1/customers/{customerRef} · portée registry

Ce qu'Agreely détient pour cette référence, en métadonnées, jamais en clair :

{ "customerRef": "cust_8812", "registered": true, "source": "api",
  "hasDisplayName": true, "hasEmail": true, "hasBasisNote": true,
  "legalBasis": "contract", "noticeLocale": "fr",
  "createdAt": "2026-10-01T14:05:12Z", "updatedAt": "2026-10-08T13:15:00Z",
  "relationship": { "status": "active", "endedAt": null } }

hasEmail: true, jamais l'adresse. Agreely n'est pas un endroit où relire vos clients : c'est là que vit le registre de responsabilité. Une référence que l'organisation n'a jamais utilisée donne 404.

La relation client (relationship)

Mettre fin à une relation

POST /v1/customers/{customerRef}/relationship/end · portée relationship

Atteste, depuis votre propre code (p. ex. un flux de désinscription), qu'une relation client est terminée, au sens de l'article 23 de P-39.1 (article 73 de A-2.1 pour un organisme public) : « les fins [...] sont accomplies ». C'est la même écriture que l'action de l'interface : elle ne révoque, n'efface ni ne masque aucun consentement par cellule. Corps { "reason": "…" } requis (2 000 caractères au plus). L'appel est idempotent : le endedAt et l'origine endedBy d'origine sont préservés.

{
  "customerRef": "cust_8812",
  "status": "ended",
  "endedAt": "2026-06-26T15:04:05Z",
  "endedBy": "company"
}

Refus : 404 (référence inconnue ou étrangère, rien n'est écrit), 422 (reason absent ou vide; le message cite la loi de l'organisation).

Annuler une fin erronée

POST /v1/customers/{customerRef}/relationship/revert · portée relationship

Annule une fin prématurée ou erronée attestée par l'entreprise. C'est une correction d'une inscription inexacte (P-39.1, art. 11; A-2.1, art. 72), jamais une résurrection d'un consentement mort. Permise seulement si la fin était attestée par l'entreprise, si rien n'a été détruit (aucun consentement retiré, aucun effacement) et si l'appel arrive dans les 30 jours (une règle de produit d'Agreely, pas un délai prévu par la loi). Sinon : 404 propre, rien n'est écrit; une personne qui revient est une nouvelle relation : le consentement antérieur ne valait que pour la durée nécessaire à ses fins (P-39.1, art. 14 al. 3; A-2.1, art. 53.1 al. 3). Corps { "reason": "…" } requis.

{ "customerRef": "cust_8812", "status": "active", "reverted": true }

La conservation (registry, retention, holds)

Votre organisation décide, Agreely consigne et rappelle, le système hôte exécute et rend compte. Rien ici ne vérifie ce que fait le système hôte : chaque écriture consigne une déclaration ("status": "declared"), jamais une vérification.

Lire la posture de conservation d'un client

GET /v1/customers/{customerRef}/retention · portée registry

{
  "customerRef": "cust_8812",
  "relationship": {
    "status": "ended",
    "endedAt": "2026-03-31T20:00:00Z"
  },
  "clock": {
    "trigger": "purpose_achieved",
    "recipe": "dueAt = the tenant-local calendar date of endedAt, plus periodMonths calendar months",
    "dueAt": null,
    "reason": "hold_active",
    "rules": [
      {
        "ruleId": "b7e0…",
        "label": "Dossiers clients",
        "labelEn": "Customer files",
        "periodMonths": 24,
        "action": "destroy",
        "dueAt": "2028-03-31",
        "held": true
      }
    ]
  },
  "disposition": null,
  "holds": [
    {
      "id": "3c9d…",
      "status": "active",
      "scope": "all",
      "placedBy": "api",
      "startedOn": "2026-10-08",
      "placedAt": "2026-10-08T13:00:00Z",
      "releasedAt": null,
      "ground": "rights_request",
      "hasProvision": false,
      "reviewOn": null,
      "hasReleaseReason": false
    }
  ],
  "releasedTruncated": false
}
  • clock.rules[].dueAt est l'échéance arithmétique de chaque règle; held: true dit qu'une retenue active la suspend, et que cette date n'est pas une date à laquelle agir.
  • clock.dueAt est l'échéance la plus proche d'une règle qu'aucune retenue ne suspend. Elle est null avec reason : no_declared_rule (aucune règle déclarée, et aucune durée n'est inventée), relationship_active ou hold_active (chaque règle est retenue).
  • holds liste chaque retenue active et les 20 dernières levées (releasedTruncated quand il y en a davantage). placedBy vaut api ou organization; ground, hasProvision, reviewOn et hasReleaseReason ne sont relus que pour une retenue que l'API a placée. Jamais le texte d'une disposition légale ni d'un motif de levée.
  • disposition est la déclaration en vigueur, avec son agreelyIdentity.

Une référence inconnue, étrangère ou malformée donne le même 404.

Déclarer ce qui a été fait des renseignements

POST /v1/customers/{customerRef}/retention/dispositions · portée registry

Le système hôte déclare destroyed, anonymized ou legal_hold pour un client dont la relation est terminée.

{ "disposition": "destroyed", "scheduleRef": "CC-2026-04" }

reason est requis pour legal_hold (il nomme la loi, pas la personne), et retentionUntil (AAAA-MM-JJ) n'est permis qu'avec legal_hold. Le registre est en ajout seulement : une correction est un nouvel ajout qui remplace la déclaration en vigueur. Réponse 201 (ajout) ou 200 (déclaration identique déjà en vigueur, rejouée avec appended: false) :

{
  "customerRef": "cust_8812",
  "recorded": true,
  "status": "declared",
  "appended": true,
  "id": "d1f0…",
  "disposition": "destroyed",
  "retentionUntil": null,
  "hasReason": false,
  "endedAtSnapshot": "2026-03-31T20:00:00Z",
  "scheduleRef": "CC-2026-04",
  "basis": "contract",
  "hasBasisNote": true,
  "declaredBy": "api:purge-nocturne",
  "declaredAt": "2026-10-09T03:00:00Z",
  "agreelyIdentity": "erased",
  "warnings": []
}

Une déclaration de destruction retire l'identité détenue par Agreely

Une déclaration destroyed ou anonymized ajoutée (pas rejouée) retire, dans la même transaction, le nom, le courriel, la note de base et la langue d'avis que le registre d'Agreely détient pour ce client. La référence, le motif déclaré, les déclarations, la preuve de consentement et le registre des demandes de droits restent. Rien n'est retiré sur un legal_hold, ni tant qu'une retenue sur tous les renseignements du client est en place (la levée de cette retenue les retire alors). Les sauvegardes existantes ne sont pas modifiées.

agreelyIdentity dit ce que la déclaration a fait : erased (l'identité a été retirée), none_held (rien n'était détenu), retained_hold (une retenue la conserve) ou retained (un legal_hold); null pour une déclaration antérieure à ce champ. Une déclaration faite pendant qu'une retenue est active est consignée, jamais refusée, avec un avertissement { "code": "hold_active", "holdIds": […] } dans warnings.

Refus : 404; 409 relationship_active (la relation n'est pas terminée : mettez-y fin d'abord, ou placez une retenue pour conserver des renseignements pendant qu'elle se poursuit); 422 invalid_request.

Placer une retenue de conservation

POST /v1/customers/{customerRef}/retention/holds · portée registry · honore Idempotency-Key

Consigne que les renseignements d'un client doivent être conservés malgré les règles de conservation, sur une relation en cours ou terminée.

{
  "ground": "other_law",
  "provision": "Une obligation de conservation prévue par une autre loi, citée par l'organisation",
  "scope": {
    "rules": [
      "b7e0…"
    ],
    "cells": []
  },
  "startedOn": "2026-10-08",
  "reviewOn": "2027-04-08"
}
  • ground (requis) : rights_request (les renseignements font l'objet d'une demande d'accès ou de rectification : P-39.1, art. 36; A-2.1, art. 102.1) ou other_law.
  • provision : requis pour other_law, refusé sinon. La disposition est consignée telle qu'écrite, jamais vérifiée (2 000 caractères au plus). Ce que la loi réserve diffère selon le régime : P-39.1 (art. 23) réserve un délai de conservation prévu par une loi; A-2.1 (art. 73) ne réserve que la Loi sur les archives et le Code des professions.
  • scope : "all" (par défaut), ou { "rules": [...], "cells": [...] } avec les clés de GET /v1/retention/rules et de GET /v1/catalog/cells (200 de chaque au plus). Seule une retenue sur all conserve aussi l'identité au registre après une déclaration de destruction.
  • startedOn (AAAA-MM-JJ, aujourd'hui ou avant au calendrier de l'organisation, aujourd'hui par défaut) et reviewOn (un rappel, jamais une levée automatique).

Rien ne lève une retenue de lui-même : la levée est un acte explicite, avec un motif. Réponse 201 avec la retenue (customerRef, recorded: true, replayed: false), ou 200 quand la même Idempotency-Key et le même corps rejouent la première réponse. Un système hôte ne lie jamais une demande de droits : le registre des demandes place lui-même ses retenues liées.

Refus : 404 (référence inconnue, étrangère, malformée ou trop longue); 422 invalid_request (clé de règle ou de cellule inconnue, étrangère ou malformée, champ invalide), unknown_field (tout champ non défini, dont rightsRequestId) ou too_many_holds (50 retenues actives par client); 429 hold_budget_exhausted (1 000 retenues placées par l'API par 24 heures).

Lever une retenue de conservation

POST /v1/customers/{customerRef}/retention/holds/{holdId}/release · portée registry · honore Idempotency-Key

Lève n'importe quelle retenue active de l'organisation sur ce client, y compris celles que le registre des demandes de droits ou un membre de l'organisation ont placées. Corps { "reason": "…" } requis (2 000 caractères au plus, chiffré, jamais renvoyé); tout autre champ donne 422 unknown_field. La levée est consignée et attribuée à la clé.

{
  "customerRef": "cust_8812",
  "recorded": true,
  "id": "3c9d…",
  "status": "released",
  "scope": "all",
  "placedBy": "organization",
  "startedOn": "2026-10-01",
  "placedAt": "2026-10-01T15:00:00Z",
  "releasedAt": "2026-10-09T14:05:12Z",
  "agreelyIdentity": null
}

Lever une retenue que le registre des demandes a placée est consigné et jamais refusé; toute obligation de conserver les renseignements visés jusqu'à l'épuisement des recours de la personne reste celle de l'organisation, et Agreely ne la vérifie pas. Chaque levée d'une retenue que votre système n'a pas placée est signalée par courriel au propriétaire de l'organisation, et plafonnée à 20 par 24 heures glissantes; lever les retenues que votre système a placées n'est pas plafonné.

Quand c'est la dernière retenue active sur tous les renseignements et qu'une déclaration de destruction ou d'anonymisation est en vigueur, la levée retire l'identité du registre dans la même transaction : agreelyIdentity vaut alors erased. Sinon retained_hold, none_held ou null (aucune telle déclaration).

Refus : 404 (identifiant de retenue inconnu, étranger, malformé ou d'un autre client); 409 already_released; 422; 429 hold_release_cap_reached.

Lire le flux des retenues

GET /v1/retention/holds · portée holds · filtres changedSince et pageToken

Ce qu'une tâche de purge lit avant de purger : les références à épargner et la portée de chaque retenue, jamais le motif, l'origine, la disposition légale ni une date autre que changedAt. La portée holds est accordée séparément et n'est jamais cochée par défaut : la clé d'une tâche de purge porte délibérément retention et holds.

{
  "holds": [
    {
      "id": "3c9d…",
      "customerRef": "cust_8812",
      "status": "active",
      "scope": "all",
      "changedAt": "2026-10-08T13:00:00Z"
    }
  ],
  "nextPageToken": null,
  "cursor": "2026-10-09T13:58:00Z"
}
  • Sans changedSince : chaque retenue active, un instantané qui remplace l'ensemble actif de votre système.
  • Avec changedSince : chaque retenue placée ou levée depuis, avec son status.
  • Pages de 500. Renvoyez nextPageToken tel quel comme pageToken jusqu'à une page qui répond cursor, le changedSince de la prochaine synchronisation. Le jeton est opaque mais non signé : renvoyez-le sans le modifier.
  • Le curseur traîne derrière la lecture, si bien qu'une retenue en cours de validation est renvoyée plutôt que manquée : la livraison est au moins une fois, mettez à jour par id.
  • Un changedSince ou un pageToken malformé ou dans le futur donne 422.

En cas de doute, ne purgez rien

Traitez un 401, un 403 ou une erreur 5xx sur ce flux comme « ne rien purger ». Une purge faite sans connaître les retenues en place ne peut pas se défaire.

Lire les règles de conservation

GET /v1/retention/rules · portée retention · filtre facultatif changedSince

Chaque règle de l'entreprise, archivées comprises, avec sa durée décidée :

{
  "cursor": "2026-10-09T13:55:00Z",
  "rules": [
    {
      "key": "b7e0…",
      "label": "Dossiers clients",
      "labelEn": "Customer files",
      "duration": {
        "value": 24,
        "unit": "months"
      },
      "trigger": "purpose_achieved",
      "action": "destroy",
      "anonymizationProcessKey": null,
      "status": "active",
      "archivedAt": null,
      "updatedAt": "2026-09-01T12:00:00Z"
    }
  ],
  "ruleKeys": [
    "b7e0…"
  ]
}

Passez le cursor précédent comme changedSince pour ne recevoir que les règles changées depuis; ruleKeys porte toujours l'ensemble complet des clés, si bien qu'une règle supprimée se voit comme une clé disparue. trigger vaut collection, purpose_achieved, last_activity ou decision; action vaut destroy ou anonymize.

GET /v1/retention/rules/{ruleKey} renvoie une règle et lastReports, les derniers instants de purge et de passe déclarés par l'hôte, chacun à côté de leur réception par Agreely. Ce ne sont ni un indicateur de retard ni une prochaine échéance. Une clé inconnue, étrangère ou malformée donne le même 404.

Déclarer une purge

POST /v1/retention/rules/{ruleKey}/purges · portée retention · Idempotency-Key obligatoire

{
  "ranAt": "2026-10-09T03:00:00-04:00",
  "recordsAffected": 42,
  "method": "destroyed",
  "coveredFrom": "2024-01-01",
  "coveredUntil": "2024-09-30",
  "hostSystem": "crm",
  "hostCategory": "dossiers-clients"
}

method vaut destroyed ou anonymized (anonymizationProcessKey requis avec anonymized, refusé avec destroyed). hostSystem et hostCategory sont des identifiants courts (minuscules, chiffres, ., _, -) qui nomment un système ou un type de dossiers, jamais un dossier. references, facultatif, liste au plus 1 000 identifiants de vos propres dossiers (pas plus que recordsAffected); Agreely n'en garde jamais le texte, seulement une recherche à clé, un sel chiffré et un engagement salé. Réponse 201 avec la déclaration (status: "declared", referencesRecorded, warnings), ou 200 pour un rejeu. Une méthode différente de l'action de la règle est consignée telle que déclarée et signalée dans warnings (method_differs_from_rule). Une passe qui n'a rien trouvé est une passe, pas une purge de zéro dossier.

Refus : 404; 409 retry (une nouvelle tentative concurrente n'a pas pu être réglée : réessayez avec la même clé); 422 idempotency_key_required, idempotency_key_reused, unknown_field, invalid_method, anonymization_process_required, invalid_anonymization_process, invalid_records_affected, ran_at_in_future, invalid_covered_period, invalid_host_token, host_token_limit.

Déclarer une passe sans purge

POST /v1/retention/rules/{ruleKey}/sweeps · portée retention · Idempotency-Key obligatoire

Le battement de cœur : le système hôte déclare avoir passé sous cette règle sans rien trouver à purger. Sans lui, un hôte qui a cessé de purger et un hôte qui n'a rien à purger se ressemblent. Corps { "sweptAt": "…", "hostSystem": "crm" }; sweptAt doit tomber dans les 24 dernières heures. Réponse 201 avec declaredNothingDue: true : ce que l'hôte a dit, jamais un fait établi par Agreely. Refus : 404; 422 swept_at_in_future et les refus d'entrée de la purge; 429 sweep_too_frequent.

L'inventaire (inventory)

Déclarer les ensembles de dossiers d'un système

PUT /v1/inventory/categories · portée inventory

Un système hôte déclare la liste complète de ses ensembles de dossiers et le nom de leurs champs. Jamais une valeur : on déclare qu'un ensemble a un champ « Date de naissance », on n'envoie jamais une date de naissance.

{
  "hostSystem": "crm",
  "categories": [
    {
      "key": "dossiers-clients",
      "label": "Dossiers clients",
      "labelEn": "Customer files",
      "fields": [
        {
          "key": "date-naissance",
          "label": "Date de naissance"
        }
      ]
    }
  ]
}

Un ensemble qui n'est plus déclaré est retiré (il reste visible, marqué withdrawn). Une liste vide est refusée plutôt que lue comme « tout retirer ». Réponse 200 : { "hostSystem", "withdrawn", "categories" }, chaque ensemble avec ce que votre organisation a décidé pour lui (decision.state : decided, no_cell ou no_rule) et son retentionStatement.

Limites : 200 ensembles par déclaration, 60 champs par ensemble, 120 caractères par libellé d'ensemble, 200 par libellé de champ, un corps de 256 Kio. Au plus 1 000 nouvelles clés d'ensemble par 30 jours glissants (422 new_set_limit; une clé déjà déclarée est toujours acceptée), et 10 nouveaux systèmes hôtes par 30 jours (422 host_system_limit). Un libellé qui a la forme d'une valeur (une adresse courriel, une suite de cinq chiffres, une date) est refusé.

Refus : 413 body_too_large; 422 invalid_request, unknown_field, value_shaped_label, invalid_key, duplicate_key, limit_exceeded, empty_declaration, new_set_limit, host_system_limit. Un refus nomme un chemin (categories[3].fields[0].label), jamais ce qui y a été envoyé.

Ce que ces filtres ne peuvent pas faire

« Inscriptions de Jean-Paul » est un libellé bien formé : aucun filtre ne distingue le nom d'une personne de celui d'une catégorie. Les libellés et les clés ne s'affichent que sur les surfaces de votre propre organisation, où le responsable les lit au registre; ils n'atteignent jamais une autre entreprise, ni une ancre sur la chaîne.

Lire l'inventaire

GET /v1/inventory/categories · portée inventory ou retention · filtre facultatif hostSystem

Les ensembles déclarés, chacun avec la cellule et la règle auxquelles Agreely l'a rattaché, et son énoncé de conservation.

Lire un énoncé de conservation figé

GET /v1/inventory/statements/{statementKey} · portée inventory, retention ou check

L'énoncé qu'un système hôte a apposé sur un dossier à la collecte. Il est figé : un dossier recueilli sous « 3 mois » garde une clé qui se résout à « 3 mois » après que la règle soit passée à 2.

{
  "categoryKey": "dossiers-clients",
  "hostSystem": "crm",
  "current": true,
  "supersededAt": null,
  "key": "8a7b…",
  "revision": 2,
  "frozenAt": "2026-09-01T12:00:00Z",
  "decided": true,
  "duration": {
    "value": 24,
    "unit": "months"
  },
  "trigger": "purpose_achieved",
  "action": "destroy",
  "text": {
    "fr": "…",
    "en": "…"
  }
}

Un ensemble sans durée décidée a un énoncé decided: false, avec duration et text à null : le système hôte s'abstient, il n'invente jamais de chiffre. Une clé inconnue donne 404.

Ensuite