{"openapi":"3.1.0","info":{"title":"API publique QR Suisse — v1","version":"1.1.1","description":"API REST publique de QR Suisse, disponible avec les plans Pro et Entreprise. Documente les\n8 endpoints exposés sous `/api/v1/*`, dans leur version 1.1.1. Au sein de la v1, les\névolutions sont des ajouts compatibles, à quelques changements de comportement près, listés\ndans « Historique des versions ».\n\n## Authentification\n\nEn-tête `Authorization: Bearer qrs_live_<apiKeyId>.<secret>` — chaîne unique copiée telle\nquelle depuis le panneau de révélation du dashboard (création ou rotation de la clé), à\ncoller directement dans l'en-tête. **Ce n'est pas un JWT** : la partie avant le premier `.`\n(après le préfixe fixe `qrs_live_`) est l'identifiant de la clé (recherche directe, jamais\nsecrète), la partie après le `.` est le secret opaque à haute entropie, comparé en temps\nconstant côté serveur. Un en-tête absent, mal formé, une clé révoquée/inexistante, une clé\ngelée (voir ci-dessous) ou un secret incorrect renvoient **tous** la même erreur\n`401 unauthorized` — aucune de ces situations n'est distinguée dans la réponse\n(anti-énumération). Chaque `401` porte le même en-tête\n`WWW-Authenticate: Bearer realm=\"qrsuisse-api\"` (RFC 6750), sans détail sur le motif du refus.\n\nLe nom du schéma, `Bearer`, est reconnu sans tenir compte de la casse (RFC 9110) ; il est suivi\nd'une seule espace, puis de la clé. La clé, elle, respecte la casse : envoyez-la exactement\ntelle qu'elle a été copiée.\n\n**HTTPS uniquement ; une clé envoyée en HTTP doit être tournée.** L'API ne répond qu'en HTTPS.\nUne requête envoyée en HTTP (`http://`) n'est ni redirigée ni traitée : elle reçoit\n`403 https_required`, avant toute vérification de la clé. La clé a alors déjà circulé en clair\nsur le réseau : considérez-la comme exposée et faites-la tourner depuis le tableau de bord\n(voir « Rotation et révocation »).\n\nL'accès à l'API dans son ensemble est une capacité des **plans Pro et Entreprise**, vérifiée\nà chaque requête (jamais mise en cache). Chaque plan autorise un nombre maximal de clés\nactives simultanément : **1 avec le plan Pro, 10 avec le plan Entreprise**. Quand un\nchangement de plan réduit cette limite, les clés en excédent (les plus récentes) sont\n**gelées** — ni révoquées ni supprimées — et redeviennent utilisables automatiquement dès\nqu'un plan autorisant davantage de clés est de nouveau actif. Quand un abonnement Pro ou\nEntreprise prend fin et que le compte repasse au plan Gratuit, **toutes** ses clés sont\ngelées : elles cessent immédiatement de fonctionner, y compris en lecture, avec\n`401 unauthorized`. `403 plan_required` ne concerne qu'une clé non gelée d'un compte qui\nn'est plus sur un plan Pro ou Entreprise (cas limite, hors fin normale d'un abonnement).\n\n## Obtenir une clé\n\nUne clé API se crée uniquement depuis le tableau de bord de QR Suisse, par un membre d'un compte\nPro ou Entreprise qui a la permission « Gérant des clés API » (ou Administrateur) ; ce membre\nchoisit aussi les droits de la clé. Il n'existe pas d'OAuth, pas d'enregistrement automatique de\nclient et aucun endpoint de l'API qui délivre une clé : un agent ou un outil ne peut pas obtenir\nune clé par lui-même, c'est ce membre qui la lui remet. Un agent qui n'a pas de clé demande donc\nà l'utilisateur d'en faire créer une avec les droits nécessaires et de la lui fournir par sa\nconfiguration. La clé complète n'est affichée qu'une seule fois, à sa création ou à sa rotation\n: QR Suisse ne la conserve pas en clair et ne peut pas la renvoyer.\n\n## Compte suspendu ou en attente de suppression\n\nQuand le compte propriétaire de la clé est **suspendu** par QR Suisse, ou **en attente de\nsuppression** pour inactivité, toute requête avec une clé valide renvoie\n`403 account_disabled`, **lecture comprise**. Le message précise lequel des deux états\ns'applique, jamais le motif de la suspension. Ce contrôle a lieu après la vérification de\nla clé et avant celle du plan et des droits : une clé invalide, révoquée ou gelée reste en\n`401 unauthorized`. La clé n'est ni révoquée ni gelée : inutile de la régénérer, une action\nhumaine du titulaire du compte est nécessaire. À la levée d'une suspension, elle\nrefonctionne automatiquement, sans rotation. L'attente de suppression ne concerne que des\ncomptes au plan Gratuit : après réactivation, le compte reste au plan Gratuit et les règles\nhabituelles s'appliquent (clé gelée, donc `401 unauthorized`, ou `403 plan_required`).\n\n## Droits atomiques d'une clé\n\nUne clé porte un sous-ensemble des 5 droits atomiques suivants : `QR_CREATE`, `QR_READ`,\n`QR_UPDATE`, `QR_DELETE`, `STATS_READ`. Chaque opération indique le droit qu'elle requiert («\nDroit requis ») — ce n'est **pas** modélisé par des scopes OAuth2 (un seul schéma\nd'authentification Bearer existe dans cette API, pas un mécanisme de scopes standard). Un appel\navec une clé authentifiée mais dépourvue du droit requis renvoie `403 forbidden`.\n\n## Isolation multi-tenant\n\nChaque clé n'agit que sur les ressources du compte qui l'a émise. Une ressource absente OU\nappartenant à un autre compte renvoie **toujours** `404 not_found` — jamais `403` : l'API ne\nconfirme jamais l'existence d'une ressource d'un autre compte.\n\n## Rotation et révocation\n\nLa **rotation** d'une clé, depuis le tableau de bord, génère un nouveau secret et invalide\nl'ancien au même instant : une clé n'a jamais deux secrets valides en même temps. Seul le secret\nchange : la clé garde son identifiant (la partie avant le `.`), son nom et ses droits ; tant que\nl'outil qui l'utilise n'a pas reçu la nouvelle valeur, ses appels sont refusés avec `401\nunauthorized`. La **révocation** rend la clé inutilisable immédiatement et définitivement (`401\nunauthorized`) : on ne peut plus ni la réactiver ni la faire tourner. QR Suisse peut aussi\nrévoquer toutes les clés d'un compte, actives ou gelées, à la demande d'un administrateur du\ncompte ou si une clé a pu être exposée ou utilisée sans l'accord du titulaire ; les\nadministrateurs du compte en sont informés par e-mail. Ni la rotation ni la révocation ne\nmodifient ni ne suppriment aucun QR code.\n\n## Protection de la clé\n\n- Traitez la clé complète comme un mot de passe : conservez-la dans un gestionnaire de secrets\n  ou une variable d'environnement, jamais dans du code versionné, un fichier partagé, un ticket\n  ou une capture d'écran.\n- Ne collez jamais la clé dans une conversation partagée ou conservée, y compris avec un\n  assistant IA. Un agent reçoit la clé par sa configuration (variable d'environnement,\n  gestionnaire de secrets) et ne demande jamais à l'utilisateur de la saisir dans la\n  conversation.\n- Envoyez-la seulement dans l'en-tête `Authorization`, en HTTPS, à\n  `https://app.qrsuisse.ch/api/v1` — jamais dans une URL. Ne journalisez pas cet en-tête.\n- Ne donnez à une clé que les droits nécessaires : par exemple `QR_READ` et `STATS_READ` (le\n  préréglage « Agent IA assistant » du tableau de bord) pour un agent qui ne fait que consulter.\n  Avec le plan Entreprise, utilisez une clé par outil, pour pouvoir en révoquer une sans couper\n  les autres.\n- Si la clé a pu être exposée, faites-la tourner ou révoquez-la immédiatement depuis le tableau\n  de bord ; si le compte est au plan Gratuit, écrivez à contact@qrsuisse.ch depuis l'adresse\n  e-mail d'un administrateur du compte. Un agent qui pense que sa clé a pu être exposée le\n  signale au titulaire du compte.\n\n## Limitation de débit (rate limiting)\n\nTrois seaux indépendants :\n- **Générique** — 30 requêtes / 60 secondes par clé API, appliqué à **toute** requête\n  authentifiée sur les 8 endpoints.\n- **Création** — 8 requêtes / 60 secondes par clé API, appliqué **en plus** du seau\n  générique, uniquement sur `POST /qr-codes`.\n- **Image PNG** — 10 rendus / 60 secondes **par compte** (partagé entre toutes les clés du\n  compte), appliqué **en plus** du seau générique, uniquement sur\n  `GET /qr-codes/{id}/image` au format PNG. Ni le format SVG ni une réponse `304` ne le\n  décomptent.\n\nUn dépassement renvoie `429 rate_limited` avec un en-tête `Retry-After` (secondes). Les\nseaux d'une clé sont décomptés dès que la clé et son secret sont reconnus, y compris quand\nla requête est ensuite refusée (`403 account_disabled`, `plan_required` ou `forbidden`) :\nune fois un seau épuisé, c'est `429 rate_limited` qui est renvoyé, avant ces refus.\n\nLes requêtes refusées en `401 unauthorized` sont en plus limitées par adresse IP :\n60 refus / 60 secondes par adresse IPv4 ou par réseau IPv6 /64, et 300 refus / 60 secondes\npar réseau IPv6 /48. Au-delà, l'API renvoie `429 rate_limited` (avec `Retry-After`) sans\nvérifier la clé, même valide, jusqu'à la fin de la fenêtre.\n\n## Quota commercial de création\n\n`POST /qr-codes` est en plus soumis à un quota mensuel de créations : **5 créations par\nmois civil UTC avec le plan Pro, 100 avec le plan Entreprise**, puis le solde des packs de\ncréations API achetés (achat unique, consommés après le quota mensuel, du plus ancien au\nplus récent). Chaque création réussie est comptée, même si le QR code est supprimé\nensuite : une suppression ne rend aucune création. Le quota mensuel n'est pas reportable et se réinitialise le 1ᵉʳ de chaque\nmois à 00:00 UTC. Quand le quota mensuel et tous les packs disponibles sont épuisés, la\ncréation renvoie `429 quota_exceeded` avec la date de réinitialisation du quota mensuel\n(`resetAt`, ISO 8601) — blocage dur, aucune facturation à l'usage en v1.\n\n`GET /usage` donne le solde à tout moment : créations du mois, quota mensuel, crédits restants\ndans les packs et date de réinitialisation.\n\n## Format d'erreur commun\n\nToute erreur de cette API renvoie la même forme `{ \"error\": \"<code>\", \"message\": \"<texte>\",\n...détails éventuels }`. 14 codes stables : un code existant ne change jamais ; une version\nmineure peut en ajouter, uniquement pour une fonction nouvelle et facultative (voir\n« Historique des versions »). Les codes : `unauthorized` (401),\n`plan_required` (403), `forbidden` (403), `account_disabled` (403), `https_required` (403),\n`not_found` (404), `method_not_allowed` (405), `validation_error` (400), `quota_exceeded`\n(429), `rate_limited` (429), et pour `POST /qr-codes` avec l'en-tête `Idempotency-Key` :\n`idempotency_key_mismatch` (422), `idempotency_in_progress` (409), `resource_deleted` (410),\net pour `POST /qr-codes` et `PATCH /qr-codes/{id}` : `payload_too_large` (413), corps de\nrequête de plus de 64 Kio.\nLe texte de `message` est une aide à la lecture, en français comme dans les exemples de cette\nspécification, et il peut changer sans préavis. Fondez votre code sur le code `error`, jamais\nsur `message`.\nUn chemin d'URL contenant une séquence `%` invalide (ex. `%zz`) est refusé avant toute\nauthentification par `400 validation_error`. Un chemin inconnu sous `/api/v1/` renvoie\n`404 not_found`. Une méthode HTTP qu'un endpoint existant ne prend pas en charge renvoie\n`405 method_not_allowed`, avec l'en-tête `Allow` qui liste les méthodes acceptées. Ces deux\nréponses sont les mêmes avec ou sans clé API. Une requête en HTTP reçoit\n`403 https_required` avant tous ces contrôles (voir « Authentification »).\n\n## Index public\n\n`GET /api/v1`, sans authentification, renvoie un document JSON statique : nom et version de\nl'API, et liens vers sa spécification OpenAPI, son catalogue (RFC 9727), sa documentation et\nson guide d'authentification. La spécification est publiée sur\n`https://www.qrsuisse.ch/openapi.json` (et `/openapi.yaml`), en anglais par défaut ;\n`?lang=fr`, `de` ou `it` en donne la version traduite. L'index est limité à 60 requêtes / 60\nsecondes par adresse IPv4 ou par réseau IPv6 /64, et à 300 requêtes / 60 secondes par réseau\nIPv6 /48 (`429 rate_limited` au-delà).\n\nLe guide d'authentification, en anglais, est publié sur `https://www.qrsuisse.ch/auth.md`.\n\n## Bonnes pratiques\n\n- Envoyez une `Idempotency-Key` avec chaque création (une valeur aléatoire nouvelle, UUID v4\n  recommandé, aucune donnée personnelle) et renvoyez la même valeur si la requête échoue sur\n  un délai dépassé ou une coupure réseau (voir `POST /qr-codes`).\n- Sur `429 rate_limited` ou `409 idempotency_in_progress`, attendez le délai de\n  `Retry-After` avant de réessayer ; sur `429 quota_exceeded`, ne réessayez pas avant\n  `resetAt` ou l'achat d'un pack (le solde se lit avec `GET /usage`).\n- Pour paginer une liste filtrée ou triée, renvoyez les mêmes paramètres de filtre et de tri\n  avec `cursor` ; seul `limit` peut changer.\n- Dans une date avec décalage (`createdFrom`, `createdBefore`), encodez le `+` en `%2B`, ou\n  utilisez `Z`.\n- Renvoyez l'`ETag` d'une image dans `If-None-Match` pour éviter de la télécharger à nouveau,\n  et préférez le SVG pour l'impression.\n- Traitez les erreurs selon le code `error`, jamais selon `message`.\n\n## Historique des versions\n\nLa version figure dans `info.version` de cette spécification. Au sein de la v1, une version\nmineure n'ajoute que des fonctions facultatives : une intégration écrite pour la v1.0 continue\nde fonctionner sans modification, sous les réserves de la rubrique « Changements de\ncomportement » ci-dessous. Une version corrective (par exemple 1.1.1) corrige un défaut, sans\nfonction nouvelle ; elle peut refuser une requête qui n'était pas conforme à cette\ndocumentation. Une évolution incompatible ferait l'objet d'une v2, sous une autre adresse.\n\n### 1.1.1 — octobre 2026\n\nVersion corrective, sans fonction nouvelle.\n\nChangements de comportement :\n- Un corps de `POST /qr-codes` ou `PATCH /qr-codes/{id}` qui n'est pas de l'UTF-8 valide (par\n  exemple du texte encodé en ISO-8859-1) est refusé en `400 validation_error`. Auparavant, les\n  octets invalides étaient remplacés par « � » et enregistrés.\n- Deux `DELETE` simultanés sur le même QR code pouvaient encore répondre `500`, et un `PATCH`\n  sur un QR code supprimé au même moment pouvait répondre `200` sans rien enregistrer, ou\n  `500` : la requête qui trouve le QR code déjà supprimé répond désormais `404 not_found`,\n  comme annoncé en 1.1.0.\n\n### 1.1.0 — octobre 2026\n\nNouveautés :\n- **Archiver et réactiver** un QR code avec `PATCH /qr-codes/{id}` et le champ `active`\n  (`false` archive, `true` réactive), sans consommer de création. Droit `QR_UPDATE`.\n- **Filtrer, chercher et trier la liste** `GET /qr-codes` : `active`, `type`, `q` (libellé,\n  destination et fin de l'URL publique), `createdFrom`, `createdBefore`, `sort`. Pour la page\n  suivante, renvoyez les mêmes paramètres avec `cursor`.\n- **Lire le quota de créations** avec `GET /usage` : créations du mois, quota mensuel, crédits\n  de packs restants, date de réinitialisation. Droit `QR_CREATE`.\n- **Relancer une création sans doublon** avec l'en-tête facultatif `Idempotency-Key` sur\n  `POST /qr-codes` (UUID v4 recommandé, aucune donnée personnelle, 24 heures). Trois codes\n  d'erreur nouveaux, renvoyés seulement avec cet en-tête : `422 idempotency_key_mismatch`,\n  `409 idempotency_in_progress`, `410 resource_deleted`.\n\nChangements de comportement :\n- `GET /qr-codes` sans paramètre trie par date de création décroissante, puis par `id` (avant :\n  `id` décroissant seulement) ; l'ordre obtenu est en pratique le même.\n- `nextCursor` est désormais une valeur opaque. Un curseur invalide, obtenu avec d'autres\n  paramètres ou par un autre compte est refusé en `400 validation_error`. L'ancien curseur de\n  la v1.0 (l'`id` du dernier élément) reste accepté pendant toute la v1, pour la liste sans\n  filtre ni tri.\n- Les paramètres `active`, `type`, `q`, `createdFrom`, `createdBefore` et `sort` de la liste,\n  auparavant ignorés, sont maintenant appliqués et validés : une valeur invalide, vide ou\n  répétée est refusée en `400 validation_error`.\n- Un champ `active` dans le corps de `PATCH /qr-codes/{id}`, auparavant ignoré, est maintenant\n  appliqué.\n- Un corps de requête de plus de 64 Kio sur `POST /qr-codes` ou `PATCH /qr-codes/{id}` est\n  refusé en `413 payload_too_large` (nouveau code ; un corps normal n'en approche pas).\n- Un libellé contenant un caractère de contrôle (caractère nul, tabulation, retour à la\n  ligne…) est refusé en `400 validation_error`.\n- Toutes les réponses portent `Cache-Control: no-store`, sauf l'image d'un QR code\n  (`private, no-cache` avec `ETag`).\n- Un `PATCH` ou un `DELETE` sur un QR code supprimé au même moment par une autre requête\n  répond `404 not_found` au lieu de `500`.\n- Une chaîne Unicode mal formée (demi-paire de substitution isolée, par exemple `\\ud800`)\n  dans le corps de `POST /qr-codes` ou `PATCH /qr-codes/{id}` est refusée en\n  `400 validation_error` au lieu d'une erreur `500`.\n\n### 1.0.0 — septembre 2026\n\nPremière version publique : création, lecture, liste, modification et suppression des QR\ncodes (types `URL` et `VCARD`), image PNG ou SVG, statistiques de scan.\n","contact":{"name":"QR Suisse"}},"servers":[{"url":"https://app.qrsuisse.ch/api/v1","description":"Production"}],"security":[{"ApiKeyAuth":[]}],"tags":[{"name":"QR Codes","x-displayName":"QR codes","description":"Gestion des QR codes du compte propriétaire de la clé API (création, recherche, modification, archivage, suppression), téléchargement de leur image et lecture de leurs statistiques."},{"name":"Usage","x-displayName":"Quota de création","description":"Solde du quota de créations API du compte propriétaire de la clé API."}],"paths":{"/qr-codes":{"get":{"operationId":"listQrCodes","tags":["QR Codes"],"summary":"Liste des QR codes du compte","description":"Droit requis : **`QR_READ`**. Pas de quota (lecture seule).\n\nSans paramètre : tous les QR codes du compte, actifs et archivés, du plus récent au plus\nancien (date de création décroissante, puis `id`).\n\nFiltres facultatifs et cumulables : statut (`active`), type (`type`), recherche (`q`),\npériode de création (`createdFrom`, `createdBefore`), tri (`sort`). La recherche porte sur\nle libellé, la destination et la fin de l'URL publique (`slug`), jamais sur les champs\nd'une carte de contact. Une valeur invalide d'un de ces paramètres est refusée en `400\nvalidation_error` (le message nomme le paramètre), jamais remplacée par une valeur par\ndéfaut : une intégration ne reçoit jamais, sans le savoir, une autre liste que celle\ndemandée. Un paramètre indiqué deux fois, ou présent mais vide (`?active=`), est refusé\nde la même façon ; seuls `cursor` vide (première page) et `limit` font exception. Un nom\nde paramètre inconnu est ignoré.\n\nPagination par curseur opaque (`nextCursor`). Pour la page suivante, renvoyez **les mêmes\nparamètres** de filtre et de tri, avec `cursor` ; seul `limit` peut changer. Un curseur\nobtenu avec d'autres paramètres, ou par un autre compte, est refusé en `400`. Le filtre\n`accountId` de la clé reste la seule source de ce qui est renvoyé.\n","parameters":[{"$ref":"#/components/parameters/ActiveFilterParam"},{"$ref":"#/components/parameters/TypeFilterParam"},{"$ref":"#/components/parameters/SearchParam"},{"$ref":"#/components/parameters/CreatedFromParam"},{"$ref":"#/components/parameters/CreatedBeforeParam"},{"$ref":"#/components/parameters/SortParam"},{"$ref":"#/components/parameters/LimitParam"},{"$ref":"#/components/parameters/CursorParam"}],"responses":{"200":{"description":"Page de résultats.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QrCodeListPage"}}}},"400":{"description":"Valeur invalide d'un paramètre de filtre, de tri ou de curseur, paramètre répété, ou\ncurseur obtenu avec d'autres paramètres ou dont le dernier QR code a été supprimé ou\nrenommé depuis (tri par libellé). Le message nomme le paramètre, jamais sa valeur.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/ForbiddenOrPlanRequired"},"429":{"$ref":"#/components/responses/RateLimited"}}},"post":{"operationId":"createQrCode","tags":["QR Codes"],"summary":"Création d'un QR code","description":"Droit requis : **`QR_CREATE`**. Soumis au quota commercial de création (quota mensuel du\nplan : 5 en Pro, 100 en Entreprise, puis solde des packs de créations API) — vérifié dans\nla même transaction que l'écriture, contre une création concurrente proche de la limite.\nUne création reste comptée même si le QR code est supprimé ensuite.\n\n`type` est figé à la création (`URL` ou `VCARD`, discriminant du corps de la requête) et\nn'est **jamais** modifiable ensuite. Pour `VCARD`, `phones`/`emails`/`addresses` sont\nindividuellement optionnels — absents, ils sont traités comme des tableaux vides (une\ncarte minimale prénom + nom est un cas d'usage valide).\n\nConsomme le seau de rate limit générique **et** le seau dédié `api-create` (8/min, en\nplus du seau générique de 30/min).\n\n**Nouvelles tentatives sans doublon.** Avec l'en-tête facultatif `Idempotency-Key`, une\nrequête renvoyée après une coupure réseau ou un délai dépassé ne crée pas un second QR\ncode et ne consomme pas une seconde création du quota. Utilisez une valeur aléatoire\nnouvelle pour chaque création (UUID v4 recommandé) et n'y mettez aucune donnée\npersonnelle. Comportement :\n- même valeur et même corps dans les 24 heures : `201` avec le QR code déjà créé, dans son\n  état actuel, et l'en-tête `Idempotent-Replayed: true` ; rien n'est décompté du quota.\n  Ce rejeu consomme les seaux de rate limit comme une création ;\n- même valeur avec un corps différent : `422 idempotency_key_mismatch`, rien n'est créé ;\n- même valeur alors que la première requête est encore en cours : la seconde attend\n  normalement sa fin et reçoit le même QR code ; si l'attente est trop longue,\n  `409 idempotency_in_progress` avec `Retry-After` ;\n- le QR code créé avec cette valeur a été supprimé depuis : `410 resource_deleted`, rien\n  n'est recréé ;\n- une requête refusée (`400`, `403`, `429`, erreur serveur) n'est pas retenue : la même\n  valeur peut resservir une fois le problème corrigé ;\n- après 24 heures, la valeur est oubliée : une nouvelle requête avec la même valeur crée un\n  nouveau QR code et consomme une création.\n\nLa valeur d'`Idempotency-Key` est propre à chaque clé API : la même valeur envoyée avec une autre clé API crée\nun autre QR code. Seules des empreintes non réversibles de la valeur et du corps sont\nenregistrées, avec l'identifiant du QR code créé, et prises en compte pendant 24 heures.\n","parameters":[{"$ref":"#/components/parameters/IdempotencyKeyHeader"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QrCodeCreateInput"}}}},"responses":{"201":{"description":"QR code créé, ou création rejouée avec la même `Idempotency-Key`.","headers":{"Idempotent-Replayed":{"description":"`true` quand la réponse rejoue une création déjà faite avec la même\n`Idempotency-Key` (aucune nouvelle création, aucun quota consommé). Absent\nsinon.\n","schema":{"type":"string","enum":["true"]}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QrCodeApiDto"}}}},"400":{"$ref":"#/components/responses/ValidationError"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/ForbiddenOrPlanRequired"},"409":{"$ref":"#/components/responses/IdempotencyInProgress"},"410":{"$ref":"#/components/responses/ResourceDeleted"},"413":{"$ref":"#/components/responses/PayloadTooLarge"},"422":{"$ref":"#/components/responses/IdempotencyKeyMismatch"},"429":{"$ref":"#/components/responses/RateLimitedOrQuotaExceeded"}}}},"/qr-codes/{id}":{"parameters":[{"$ref":"#/components/parameters/QrCodeIdParam"}],"get":{"operationId":"getQrCode","tags":["QR Codes"],"summary":"Détail d'un QR code","description":"Droit requis : **`QR_READ`**. Pas de quota.\n","responses":{"200":{"description":"QR code trouvé.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QrCodeApiDto"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/ForbiddenOrPlanRequired"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"}}},"patch":{"operationId":"updateQrCode","tags":["QR Codes"],"summary":"Modification partielle d'un QR code","description":"Droit requis : **`QR_UPDATE`**. Pas de quota de création : ni une modification ni une\nréactivation (`active: true`) ne consomment de crédit.\n\n**Archivage et réactivation** : `active: false` archive le QR code — sa redirection publique\nrépond alors `404` (« QR code introuvable ou désactivé. »), exactement comme après un\narchivage depuis le tableau de bord — et `active: true` le réactive. `active` doit être un booléen JSON :\n`\"false\"`, `0` ou `null` sont refusés en `400 validation_error`. Si le corps ne contient\nque `active` et que le QR code est déjà dans l'état demandé, la réponse est `200` sans\naucune écriture (`updatedAt` inchangé).\n`active` se combine avec les autres champs : tout le corps est validé avant la moindre\nécriture, puis appliqué en une seule fois — une erreur `400` n'applique rien, ni\nl'archivage ni les autres champs.\n\n`type` n'est **jamais** modifiable (une clé `\"type\"` présente dans le corps est\nsilencieusement ignorée, ce n'est jamais une erreur `400`) — c'est toujours le type déjà\nenregistré en base qui détermine quels champs du corps de la requête ci-dessous\ns'appliquent réellement. Les champs qui ne concernent pas le type existant de la\nressource sont, de la même façon, silencieusement ignorés (jamais rejetés).\n\nSémantique d'un PATCH partiel : un champ absent du corps JSON laisse la valeur actuelle\ninchangée. Pour `organization`/`jobTitle`/`website` (VCARD), un champ **présent** avec une\nchaîne vide efface explicitement la valeur (mise à `null`) — c'est sa présence dans le\ncorps JSON qui compte, pas seulement sa valeur. Pour `phones`/`emails`/`addresses`\n(VCARD), un tableau **présent** (même vide) remplace l'intégralité de la catégorie.\n","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QrCodeUpdateInput"}}}},"responses":{"200":{"description":"QR code mis à jour.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QrCodeApiDto"}}}},"400":{"$ref":"#/components/responses/ValidationError"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/ForbiddenOrPlanRequired"},"404":{"$ref":"#/components/responses/NotFound"},"413":{"$ref":"#/components/responses/PayloadTooLarge"},"429":{"$ref":"#/components/responses/RateLimited"}}},"delete":{"operationId":"deleteQrCode","tags":["QR Codes"],"summary":"Suppression définitive d'un QR code","description":"Droit requis : **`QR_DELETE`**. Pas de quota. Une suppression ne rend pas la création au\nquota mensuel.\n\nSuppression **réelle** (pas un archivage) : les téléphones, emails, adresses et\nstatistiques journalières du QR code sont supprimés avec lui, et son slug est retiré\ndéfinitivement en même temps — un support déjà imprimé avec ce slug ne se remet jamais à\npointer vers un QR différent créé après coup.\n","responses":{"204":{"description":"QR code supprimé, aucun corps de réponse."},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/ForbiddenOrPlanRequired"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/qr-codes/{id}/stats":{"parameters":[{"$ref":"#/components/parameters/QrCodeIdParam"}],"get":{"operationId":"getQrCodeStats","tags":["QR Codes"],"summary":"Statistiques de scan d'un QR code","description":"Droit requis : **`STATS_READ`**. Pas de quota.\n\n`dailyStats` est potentiellement lacunaire : seuls les jours ayant enregistré au moins un\nscan possèdent une ligne (pas de complétion à 0 des jours sans scan, contrairement au\ngraphique du dashboard) — un intégrateur API consomme des données brutes, pas une\nprésentation. Fenêtre bornée à l'historique des plans Pro et Entreprise (15 mois) —\nl'accès API lui-même n'est possible qu'avec un plan Pro ou\nEntreprise.\n","responses":{"200":{"description":"Statistiques du QR code.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StatsApiDto"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/ForbiddenOrPlanRequired"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/qr-codes/{id}/image":{"parameters":[{"$ref":"#/components/parameters/QrCodeIdParam"}],"get":{"operationId":"getQrCodeImage","tags":["QR Codes"],"summary":"Image d'un QR code (PNG ou SVG)","description":"Droit requis : **`QR_READ`**. Pas de quota.\n\nRenvoie le fichier image du QR code, rendu côté serveur avec exactement les mêmes\nréglages que le dashboard (noir sur blanc, correction d'erreur de niveau M, marge de 4\nmodules) : un QR téléchargé via l'API est identique à celui du dashboard. Aucune option\nvisuelle (couleur, logo, marge) n'est paramétrable dans cette version.\n\nLe contenu encodé est l'URL publique du QR code (`publicUrl` de `GET /qr-codes/{id}`) :\nsur le domaine personnalisé du compte s'il est actif, sur le domaine standard sinon.\nL'image est servie même si le QR code est désactivé (`active: false`).\n\nLe fichier est renvoyé en pièce jointe (`Content-Disposition: attachment`), nommé\n`qr-suisse-<slug>.png` ou `qr-suisse-<slug>.svg`. La réponse porte un `ETag` : renvoyer\nsa valeur dans `If-None-Match` permet d'obtenir `304 Not Modified` sans corps si l'image\nn'a pas changé (l'appel reste authentifié, compté dans le seau générique de rate limit et\njournalisé, mais pas dans le seau dédié aux rendus PNG).\n\nRate limiting : en plus du seau générique (30 requêtes / 60 s par clé), chaque rendu PNG\nconsomme le seau dédié **10 rendus / 60 s par compte**, partagé entre toutes les clés du\ncompte. Le SVG n'est pas concerné par ce seau dédié.\nL'`ETag` change notamment quand le domaine personnalisé du compte est activé ou désactivé.\n","parameters":[{"name":"format","in":"query","required":false,"description":"Format de l'image, en minuscules. Toute autre valeur renvoie `400 validation_error`\n(jamais de repli silencieux sur un autre format).\n","schema":{"type":"string","enum":["png","svg"],"default":"png"}},{"name":"size","in":"query","required":false,"description":"PNG uniquement : côté du carré en pixels, marge incluse, entier de 128 à 1024. Une\nvaleur non entière ou hors bornes renvoie `400 validation_error`. Ignoré pour le SVG\n(vectoriel, redimensionnable sans perte — le format à privilégier pour l'impression\ngrand format).\n","schema":{"type":"integer","minimum":128,"maximum":1024,"default":1024}},{"name":"If-None-Match","in":"header","required":false,"description":"Valeur d'`ETag` obtenue lors d'un appel précédent.","schema":{"type":"string"}}],"responses":{"200":{"description":"Image du QR code.","headers":{"ETag":{"description":"Empreinte de l'image (format, taille et contenu encodé).","schema":{"type":"string"}},"Content-Disposition":{"description":"`attachment; filename=\"qr-suisse-<slug>.<png|svg>\"`","schema":{"type":"string"}},"Cache-Control":{"description":"`private, no-cache` — réponse authentifiée, jamais mise en cache partagé, revalidation à chaque utilisation.","schema":{"type":"string"}}},"content":{"image/png":{"schema":{"type":"string","format":"binary"}},"image/svg+xml":{"schema":{"type":"string"}}}},"304":{"description":"L'image n'a pas changé depuis l'`ETag` fourni dans `If-None-Match` — aucun corps.","headers":{"ETag":{"description":"Empreinte de l'image, identique à celle fournie.","schema":{"type":"string"}}}},"400":{"description":"Paramètre `format` ou `size` invalide.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/ForbiddenOrPlanRequired"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"description":"`rate_limited` — seau générique dépassé (30 requêtes / 60 s par clé API), trop de\nrequêtes refusées en `401` depuis la même adresse IP (60 / 60 s, ou 300 / 60 s par\nréseau IPv6 /48), ou, pour le format PNG, seau dédié aux rendus dépassé (10 rendus /\n60 s par compte).\n","headers":{"Retry-After":{"description":"Estimation en secondes avant que la fenêtre courante ne se libère.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitedError"}}}}}}},"/usage":{"get":{"operationId":"getUsage","tags":["Usage"],"summary":"Quota de créations API du compte","description":"Droit requis : **`QR_CREATE`**. Pas de quota : cette lecture ne consomme aucune création.\n\nRenvoie les chiffres que l'écran « Clés API » du tableau de bord affiche au même instant,\npour le compte propriétaire de la clé : créations API du mois civil UTC en cours, quota\nmensuel du plan, crédits restants dans les packs de créations API et date de\nréinitialisation du quota mensuel. Une création réussie augmente `used` de 1 ; une\nsuppression ne le fait pas baisser. Au-delà du quota mensuel, chaque création consomme\naussi un crédit de pack.\n\nValeurs indicatives : une création faite au même moment avec une autre clé du compte peut\nles rendre inexactes dès leur lecture. Seul `POST /qr-codes` vérifie le quota de façon\ncertaine (voir « Quota commercial de création »).\n","responses":{"200":{"description":"Quota de créations API du compte.","headers":{"Cache-Control":{"description":"`no-store` — la réponse change à chaque création, elle n'est jamais mise en cache.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UsageApiDto"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/ForbiddenOrPlanRequired"},"429":{"$ref":"#/components/responses/RateLimited"}}}}},"components":{"securitySchemes":{"ApiKeyAuth":{"type":"http","scheme":"bearer","bearerFormat":"qrs_live_<apiKeyId>.<secret>","description":"Jeton opaque à deux parties, **pas un JWT** : `qrs_live_<apiKeyId>.<secret>`, transmis\ndans `Authorization: Bearer <jeton>`.\n"}},"parameters":{"QrCodeIdParam":{"name":"id","in":"path","required":true,"description":"Identifiant (`cuid`) du QR code.","schema":{"type":"string"}},"LimitParam":{"name":"limit","in":"query","required":false,"description":"Nombre maximum d'éléments par page. Défaut 20. Toute valeur absente, non entière ou ≤ 0\nretombe silencieusement sur le défaut (paramètre de confort, jamais une erreur\n`400`) ; une valeur supérieure à 100 est plafonnée à 100.\n","schema":{"type":"integer","minimum":1,"maximum":100,"default":20}},"CursorParam":{"name":"cursor","in":"query","required":false,"description":"Valeur de `nextCursor` reçue à la page précédente, à renvoyer telle quelle avec les mêmes\nparamètres de filtre et de tri. Valeur opaque : n'en déduisez rien, son format peut\nchanger. Absent pour la première page. Un curseur invalide, obtenu avec d'autres\nparamètres ou par un autre compte est refusé en `400`. L'ancien curseur de la v1.0\n(l'`id` du dernier élément) reste accepté pendant toute la v1, pour la liste sans filtre\nni tri.\n","schema":{"type":"string","maxLength":512}},"ActiveFilterParam":{"name":"active","in":"query","required":false,"description":"`true` : QR codes actifs seulement ; `false` : archivés seulement. Absent : les deux.\nExactement `true` ou `false`, en minuscules.\n","schema":{"type":"string","enum":["true","false"]}},"TypeFilterParam":{"name":"type","in":"query","required":false,"description":"Un ou plusieurs types séparés par des virgules (8 valeurs au plus, majuscules ou\nminuscules) : `url` (tous les liens, `type` `URL`), `web` (page web), `email`\n(destination `mailto:`), `phone` (`tel:`), `sms` (`sms:`), `ftp` (`ftp:`), `calendar`\n(événement de calendrier), `vcard` (carte de contact). Un QR code est renvoyé s'il est de\nl'un des types indiqués.\n","style":"form","explode":false,"schema":{"type":"array","maxItems":8,"items":{"type":"string","enum":["url","web","email","phone","sms","ftp","calendar","vcard"]}},"example":["vcard","email"]},"SearchParam":{"name":"q","in":"query","required":false,"description":"Recherche dans le libellé, la destination et la fin de l'URL publique (`slug`), sans\ndistinction de casse ni d'accents (« geneve » trouve « Genève »). Jamais dans les champs\nd'une carte de contact. De 1 à 100 caractères une fois retirés les espaces de début et de\nfin, sans caractère de contrôle ; `%` et `_` sont cherchés tels quels.\n","schema":{"type":"string","minLength":1,"maxLength":100}},"CreatedFromParam":{"name":"createdFrom","in":"query","required":false,"description":"QR codes créés à partir de cette date, incluse. ISO 8601 : `AAAA-MM-JJ` (minuit UTC) ou\ndate-heure avec `Z` ou un décalage. Dans l'URL, encodez le `+` d'un décalage en `%2B`\n(`createdFrom=2026-10-01T08:00:00%2B02:00`) ou utilisez `Z`\n(`createdFrom=2026-10-01T06:00:00Z`) ; un `+` non encodé, reçu comme une espace, est\naussi accepté juste devant le décalage. Année de 1970 à 9999, calculée en UTC une fois le\ndécalage appliqué ; une date inexistante (`2026-02-30`) ou hors de cette plage est refusée\nen `400 validation_error`.\n","schema":{"type":"string"},"example":"2026-10-01"},"CreatedBeforeParam":{"name":"createdBefore","in":"query","required":false,"description":"QR codes créés avant cette date, exclue (même format que `createdFrom`). Avec\n`createdFrom=2026-10-01&createdBefore=2026-11-01` : tout le mois d'octobre (UTC).\n`createdFrom` doit être antérieur à `createdBefore`.\n","schema":{"type":"string"},"example":"2026-11-01"},"SortParam":{"name":"sort","in":"query","required":false,"description":"Ordre de la liste : `created_desc` (défaut, plus récent d'abord), `created_asc`,\n`label_asc` (libellé de A à Z), `label_desc`. Les égalités sont départagées par `id`.\nAvec `label_asc` ou `label_desc`, si le dernier QR code d'une page est supprimé ou\nrenommé avant l'appel suivant, la page suivante répond `400` (recommencez la pagination\ndepuis la première page) plutôt que d'omettre des QR codes en silence : pour\nune synchronisation complète, préférez `created_desc` ou `created_asc`.\n","schema":{"type":"string","enum":["created_desc","created_asc","label_asc","label_desc"],"default":"created_desc"}},"IdempotencyKeyHeader":{"name":"Idempotency-Key","in":"header","required":false,"description":"Clé choisie par le client pour une création logique, qui permet de renvoyer la même\nrequête sans créer de doublon (voir `POST /qr-codes`). 1 à 255 caractères ASCII\nvisibles, sans espace ; des guillemets englobants sont ignorés. UUID v4 recommandé,\naucune donnée personnelle. Une valeur mal formée renvoie `400 validation_error`.\n","schema":{"type":"string","minLength":1,"maxLength":255,"pattern":"^[\\x21-\\x7E]{1,255}$"},"example":"3f1c2b0e-8a4d-4c6e-9b7a-1d2e3f4a5b6c"}},"schemas":{"QrCodeType":{"type":"string","enum":["URL","VCARD"],"description":"Figé à la création, jamais modifiable ensuite."},"PhoneType":{"type":"string","enum":["PROFESSIONAL","MOBILE","FAX"]},"EmailType":{"type":"string","enum":["PROFESSIONAL","PERSONAL"]},"AddressType":{"type":"string","enum":["PROFESSIONAL","HOME"]},"PhoneEntry":{"type":"object","required":["type","value"],"properties":{"type":{"$ref":"#/components/schemas/PhoneType"},"value":{"type":"string","minLength":1,"maxLength":255,"description":"Numéro de téléphone tel que saisi (aucun format normalisé imposé)."}},"description":"Au plus 3 numéros de téléphone par QR code VCARD."},"EmailEntry":{"type":"object","required":["type","value"],"properties":{"type":{"$ref":"#/components/schemas/EmailType"},"value":{"type":"string","format":"email","maxLength":255}},"description":"Au plus 2 adresses email par QR code VCARD."},"AddressEntry":{"type":"object","required":["type"],"properties":{"type":{"$ref":"#/components/schemas/AddressType"},"street":{"type":"string","maxLength":255},"complement":{"type":"string","maxLength":255},"postalCode":{"type":"string","maxLength":10},"city":{"type":"string","maxLength":255}},"description":"Au plus 2 adresses postales par QR code VCARD. Les 4 champs sont\nindividuellement optionnels (aucun n'est requis pour une entrée valide).\n"},"VCardData":{"type":"object","required":["firstName","lastName","phones","emails","addresses"],"properties":{"firstName":{"type":"string","minLength":1,"maxLength":255},"lastName":{"type":"string","minLength":1,"maxLength":255},"organization":{"type":"string","maxLength":255},"jobTitle":{"type":"string","maxLength":255},"website":{"type":"string","maxLength":255},"phones":{"type":"array","items":{"$ref":"#/components/schemas/PhoneEntry"},"maxItems":3},"emails":{"type":"array","items":{"$ref":"#/components/schemas/EmailEntry"},"maxItems":2},"addresses":{"type":"array","items":{"$ref":"#/components/schemas/AddressEntry"},"maxItems":2}},"description":"Présent uniquement dans `QrCodeApiDto.vcard` pour `type === \"VCARD\"`. `organization`/\n`jobTitle`/`website` absents de la réponse s'ils ne sont pas renseignés (jamais `null`\nexplicite) — un intégrateur externe teste la présence de la clé, pas sa valeur.\n"},"QrCodeApiDto":{"type":"object","required":["id","type","label","active","scanCount","slug","publicUrl","createdAt","updatedAt"],"properties":{"id":{"type":"string","description":"Identifiant (`cuid`) du QR code."},"type":{"$ref":"#/components/schemas/QrCodeType"},"label":{"type":"string"},"active":{"type":"boolean"},"scanCount":{"type":"integer","description":"Nombre total de scans depuis la création."},"slug":{"type":"string"},"publicUrl":{"type":"string","format":"uri","description":"URL publique complète de redirection (domaine personnalisé du compte s'il est actif,\ndomaine standard sinon) — jamais stockée telle quelle, toujours recalculée à partir de\nl'état courant du compte.\n"},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"},"destinationUrl":{"type":"string","format":"uri","description":"Présent uniquement pour `type === \"URL\"`, et seulement si renseignée."},"vcard":{"allOf":[{"$ref":"#/components/schemas/VCardData"}],"description":"Présent uniquement pour `type === \"VCARD\"`."}},"example":{"id":"cm2f8k3qz0001xyz9abcd1234","type":"URL","label":"Affiche salle d'attente","active":true,"scanCount":42,"slug":"AbC123_-xy","publicUrl":"https://app.qrsuisse.ch/r/AbC123_-xy","createdAt":"2026-10-01T08:30:00.000Z","updatedAt":"2026-10-12T14:05:00.000Z","destinationUrl":"https://www.exemple.ch/rendez-vous"},"description":"Représentation publique d'un QR code : seuls les champs ci-dessous sont renvoyés, aucune\ndonnée interne du compte.\n"},"QrCodeListPage":{"type":"object","required":["data","nextCursor"],"properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/QrCodeApiDto"}},"nextCursor":{"type":["string","null"],"description":"Curseur opaque à passer en `?cursor`, avec les mêmes paramètres de filtre et de tri,\npour obtenir la page suivante ; `null` si c'est la dernière page.\n"}}},"QrCodeCreateUrlInput":{"type":"object","required":["type","label","destinationUrl"],"properties":{"type":{"type":"string","enum":["URL"]},"label":{"type":"string","minLength":1,"maxLength":255},"destinationUrl":{"type":"string","maxLength":2048,"description":"URL de destination — protocoles acceptés : `http:`/`https:` (sans restriction\nsupplémentaire), `mailto:` (partie locale avant un éventuel `?` validée comme une\nadresse email), `tel:`/`sms:` (chiffres/espaces/`+`/`-`/parenthèses uniquement,\n4 à 20 caractères, `*`/`#` explicitement interdits — risque de code USSD),\n`ftp:` (identifiants en clair dans l'autorité interdits). Aucun caractère de contrôle\n(littéral ou encodé en `%XX`).\n"}},"example":{"type":"URL","label":"Affiche salle d'attente","destinationUrl":"https://www.exemple.ch/rendez-vous"},"description":"Corps de `POST /qr-codes` pour un QR code de type `URL`."},"QrCodeCreateVcardInput":{"type":"object","required":["type","label","firstName","lastName"],"properties":{"type":{"type":"string","enum":["VCARD"]},"label":{"type":"string","minLength":1,"maxLength":255},"firstName":{"type":"string","minLength":1,"maxLength":255},"lastName":{"type":"string","minLength":1,"maxLength":255},"organization":{"type":"string","maxLength":255},"jobTitle":{"type":"string","maxLength":255},"website":{"type":"string","maxLength":255},"phones":{"type":"array","items":{"$ref":"#/components/schemas/PhoneEntry"},"maxItems":3,"default":[]},"emails":{"type":"array","items":{"$ref":"#/components/schemas/EmailEntry"},"maxItems":2,"default":[]},"addresses":{"type":"array","items":{"$ref":"#/components/schemas/AddressEntry"},"maxItems":2,"default":[]}},"example":{"type":"VCARD","label":"Carte de visite Dr Muster","firstName":"Anna","lastName":"Muster","organization":"Cabinet Muster","phones":[{"type":"PROFESSIONAL","value":"+41 27 123 45 67"}],"emails":[{"type":"PROFESSIONAL","value":"anna.muster@exemple.ch"}]},"description":"Corps de `POST /qr-codes` pour un QR code de type `VCARD`. Seuls `label`/`firstName`/\n`lastName` sont obligatoires — une carte minimale (juste un nom) est un cas d'usage\nvalide ; `phones`/`emails`/`addresses` absents équivalent à un tableau vide.\n"},"QrCodeCreateInput":{"oneOf":[{"$ref":"#/components/schemas/QrCodeCreateUrlInput"},{"$ref":"#/components/schemas/QrCodeCreateVcardInput"}],"discriminator":{"propertyName":"type","mapping":{"URL":"#/components/schemas/QrCodeCreateUrlInput","VCARD":"#/components/schemas/QrCodeCreateVcardInput"}},"description":"Union discriminée par `type`, figé à la création — mêmes règles de validation que le\ndashboard (réutilisées telles quelles, pas de schéma parallèle).\n"},"QrCodeUpdateInput":{"type":"object","properties":{"active":{"type":"boolean","description":"`false` archive le QR code, `true` le réactive. Booléen JSON strict, applicable aux deux types."},"label":{"type":"string","minLength":1,"maxLength":255},"destinationUrl":{"type":"string","maxLength":2048,"description":"Applicable uniquement si la ressource existante est de type `URL` — voir description de l'opération."},"firstName":{"type":"string","minLength":1,"maxLength":255},"lastName":{"type":"string","minLength":1,"maxLength":255},"organization":{"type":"string","maxLength":255,"description":"Une chaîne vide, si le champ est présent, efface explicitement la valeur (`null`)."},"jobTitle":{"type":"string","maxLength":255,"description":"Une chaîne vide, si le champ est présent, efface explicitement la valeur (`null`)."},"website":{"type":"string","maxLength":255,"description":"Une chaîne vide, si le champ est présent, efface explicitement la valeur (`null`)."},"phones":{"type":"array","items":{"$ref":"#/components/schemas/PhoneEntry"},"maxItems":3,"description":"Si présent (même vide), remplace l'intégralité de la catégorie."},"emails":{"type":"array","items":{"$ref":"#/components/schemas/EmailEntry"},"maxItems":2,"description":"Si présent (même vide), remplace l'intégralité de la catégorie."},"addresses":{"type":"array","items":{"$ref":"#/components/schemas/AddressEntry"},"maxItems":2,"description":"Si présent (même vide), remplace l'intégralité de la catégorie."}},"example":{"destinationUrl":"https://www.exemple.ch/nouvelle-page"},"description":"Corps de `PATCH /qr-codes/{id}` — **tous les champs sont optionnels** (sémantique de\nmodification partielle : un champ absent laisse la valeur actuelle inchangée). `type`\nn'apparaît **jamais** dans ce corps (non modifiable, voir la description de l'opération) :\nc'est le type déjà enregistré en base qui détermine lequel des champs `destinationUrl`\n(`URL`) ou `firstName`/`lastName`/`organization`/`jobTitle`/`website`/`phones`/`emails`/\n`addresses` (`VCARD`) s'applique réellement — les champs qui ne concernent pas le type\nexistant sont silencieusement ignorés, jamais rejetés.\n"},"StatsApiDto":{"type":"object","required":["qrCodeId","scanCountTotal","dailyStats"],"properties":{"qrCodeId":{"type":"string"},"scanCountTotal":{"type":"integer","description":"Total de scans depuis la création (`QrCode.scanCount`), pas seulement sur la fenêtre `dailyStats`."},"dailyStats":{"type":"array","items":{"type":"object","required":["date","count"],"properties":{"date":{"type":"string","format":"date-time"},"count":{"type":"integer"}}},"description":"Un élément par jour ayant enregistré au moins un scan (pas de complétion à 0),\nbornée à la fenêtre d'historique des plans Pro et Entreprise (15 mois).\n"}},"example":{"qrCodeId":"cm2f8k3qz0001xyz9abcd1234","scanCountTotal":42,"dailyStats":[{"date":"2026-10-11T00:00:00.000Z","count":5},{"date":"2026-10-12T00:00:00.000Z","count":3}]},"description":"DTO public de l'endpoint statistiques."},"UsageApiDto":{"type":"object","required":["used","monthlyQuota","packCreditsRemaining","resetAt"],"additionalProperties":false,"properties":{"used":{"type":"integer","minimum":0,"description":"Créations API du mois civil UTC en cours, y compris celles qui ont consommé un crédit de\npack et celles des QR codes supprimés depuis. Peut donc dépasser `monthlyQuota`.\n"},"monthlyQuota":{"type":"integer","minimum":0,"description":"Créations incluses chaque mois dans le plan (5 en Pro, 100 en Entreprise)."},"packCreditsRemaining":{"type":"integer","minimum":0,"description":"Crédits encore disponibles dans les packs de créations API du compte, tous packs utilisables confondus."},"resetAt":{"type":"string","format":"date-time","description":"Réinitialisation du quota mensuel — 1ᵉʳ jour du mois civil UTC suivant, à minuit UTC."}},"example":{"used":7,"monthlyQuota":5,"packCreditsRemaining":98,"resetAt":"2026-11-01T00:00:00.000Z"},"description":"DTO public de l'endpoint quota — ces quatre champs uniquement."},"ApiErrorBase":{"type":"object","required":["error","message"],"properties":{"error":{"type":"string","enum":["unauthorized","plan_required","forbidden","account_disabled","https_required","not_found","method_not_allowed","validation_error","quota_exceeded","rate_limited","idempotency_key_mismatch","idempotency_in_progress","resource_deleted","payload_too_large"]},"message":{"type":"string"}},"description":"Forme commune à toute erreur de l'API v1."},"UnauthorizedError":{"allOf":[{"$ref":"#/components/schemas/ApiErrorBase"}],"example":{"error":"unauthorized","message":"Authentification invalide ou absente."}},"PlanRequiredError":{"allOf":[{"$ref":"#/components/schemas/ApiErrorBase"}],"example":{"error":"plan_required","message":"Cette fonctionnalité nécessite un plan Pro ou Entreprise."}},"ForbiddenError":{"allOf":[{"$ref":"#/components/schemas/ApiErrorBase"}],"example":{"error":"forbidden","message":"Cette clé API n'a pas le droit requis pour cette opération."}},"AccountDisabledError":{"allOf":[{"$ref":"#/components/schemas/ApiErrorBase"}],"example":{"error":"account_disabled","message":"Ce compte a été suspendu. Contactez le support QR Suisse à l'adresse contact@qrsuisse.ch pour plus d'informations."},"description":"Compte suspendu ou en attente de suppression. `message` vaut, selon l'état :\n- suspendu : « Ce compte a été suspendu. Contactez le support QR Suisse à l'adresse\n  contact@qrsuisse.ch pour plus d'informations. »\n- en attente de suppression : « Ce compte est en attente de suppression pour\n  inactivité. Utilisez le lien de récupération reçu par email pour le réactiver. »\n"},"HttpsRequiredError":{"allOf":[{"$ref":"#/components/schemas/ApiErrorBase"}],"example":{"error":"https_required","message":"L'API n'accepte que HTTPS : la requête n'a pas été traitée. Une clé envoyée en HTTP a circulé en clair, faites-la tourner depuis le tableau de bord."},"description":"Requête reçue en HTTP : ni redirigée ni traitée, avant toute vérification de la clé. Une\nclé envoyée ainsi a circulé en clair et doit être tournée.\n"},"NotFoundError":{"allOf":[{"$ref":"#/components/schemas/ApiErrorBase"}],"example":{"error":"not_found","message":"Ressource introuvable."}},"ValidationError":{"allOf":[{"$ref":"#/components/schemas/ApiErrorBase"}],"example":{"error":"validation_error","message":"Le libellé est requis"},"description":"`message` reprend le premier message de validation rencontré (ex. champ obligatoire\nabsent ou trop long) — le texte exact varie selon le champ en cause, la forme `{ error, message }`\nreste toujours la même. Ce texte est en français ; un message générique nomme le champ en\ncause (ex. « Champ « type » requis : valeurs acceptées URL, VCARD. ») : seul le code\n`error` est garanti.\n"},"QuotaExceededError":{"allOf":[{"$ref":"#/components/schemas/ApiErrorBase"},{"type":"object","required":["resetAt"],"properties":{"resetAt":{"type":"string","format":"date-time","description":"1ᵉʳ jour du mois civil UTC suivant, à minuit UTC — date de réinitialisation du quota."}}}],"example":{"error":"quota_exceeded","message":"Quota mensuel de créations API atteint.","resetAt":"2026-10-01T00:00:00.000Z"}},"RateLimitedError":{"allOf":[{"$ref":"#/components/schemas/ApiErrorBase"}],"example":{"error":"rate_limited","message":"Trop de requêtes pour cette clé API, réessayez plus tard."}},"IdempotencyKeyMismatchError":{"allOf":[{"$ref":"#/components/schemas/ApiErrorBase"}],"example":{"error":"idempotency_key_mismatch","message":"Cette clé Idempotency-Key a déjà servi pour une requête différente. Utilisez une nouvelle clé pour une nouvelle création."}},"IdempotencyInProgressError":{"allOf":[{"$ref":"#/components/schemas/ApiErrorBase"}],"example":{"error":"idempotency_in_progress","message":"Une requête avec cette clé Idempotency-Key est encore en cours de traitement, réessayez dans un instant."}},"ResourceDeletedError":{"allOf":[{"$ref":"#/components/schemas/ApiErrorBase"}],"example":{"error":"resource_deleted","message":"Le QR code créé avec cette clé Idempotency-Key a été supprimé depuis. Utilisez une nouvelle clé pour une nouvelle création."}},"PayloadTooLargeError":{"allOf":[{"$ref":"#/components/schemas/ApiErrorBase"}],"example":{"error":"payload_too_large","message":"Corps de la requête trop volumineux : 64 Kio maximum."}}},"responses":{"Unauthorized":{"description":"En-tête `Authorization` absent/mal formé, clé inconnue/révoquée/gelée, ou secret incorrect.","headers":{"WWW-Authenticate":{"description":"Toujours `Bearer realm=\"qrsuisse-api\"`, quel que soit le motif du refus.","schema":{"type":"string","const":"Bearer realm=\"qrsuisse-api\""}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedError"}}}},"ForbiddenOrPlanRequired":{"description":"`403` — soit la requête a été envoyée en HTTP (`https_required`, avant toute\nvérification de la clé), soit le compte est suspendu ou en attente de suppression\n(`account_disabled`, vérifié en premier pour une clé valide), soit le compte n'est plus\nsur un plan Pro ou Entreprise (`plan_required`), soit la clé API authentifiée n'a pas le\ndroit atomique requis pour cette opération (`forbidden`).\n","content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/HttpsRequiredError"},{"$ref":"#/components/schemas/AccountDisabledError"},{"$ref":"#/components/schemas/PlanRequiredError"},{"$ref":"#/components/schemas/ForbiddenError"}]}}}},"NotFound":{"description":"Ressource absente, ou appartenant à un autre compte (jamais distingué d'une absence pure).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NotFoundError"}}}},"ValidationError":{"description":"Corps de requête absent, non JSON, ou ne respectant pas les règles de validation du champ concerné.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}}},"RateLimited":{"description":"Seau de rate limit générique dépassé (30 requêtes / 60 s par clé API), ou trop de\nrequêtes refusées en `401` depuis la même adresse IP (60 / 60 s, ou 300 / 60 s par\nréseau IPv6 /48).\n","headers":{"Retry-After":{"description":"Estimation en secondes avant que la fenêtre courante ne se libère.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitedError"}}}},"RateLimitedOrQuotaExceeded":{"description":"`429` — soit un seau de rate limit dépassé (générique 30/min, dédié création 8/min, ou\n60 refus `401`/min depuis la même adresse IP, 300/min par réseau IPv6 /48 —\n`rate_limited`), soit le quota mensuel commercial de créations API atteint\n(`quota_exceeded`, avant même toute écriture en base).\n","headers":{"Retry-After":{"description":"Présent uniquement pour `rate_limited` — estimation en secondes avant que la fenêtre courante ne se libère.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/RateLimitedError"},{"$ref":"#/components/schemas/QuotaExceededError"}]}}}},"IdempotencyKeyMismatch":{"description":"`422` — la même `Idempotency-Key` a déjà servi, dans les 24 heures, pour une requête dont\nle corps est différent. Rien n'est créé, aucun quota n'est consommé.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/IdempotencyKeyMismatchError"}}}},"IdempotencyInProgress":{"description":"`409` — une requête avec la même `Idempotency-Key` est encore en cours et l'attente de\nson résultat a été trop longue. Rien n'est créé par cette requête : renvoyez-la après\n`Retry-After`, elle recevra le QR code créé par la première.\n","headers":{"Retry-After":{"description":"Délai conseillé avant une nouvelle tentative, en secondes.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/IdempotencyInProgressError"}}}},"ResourceDeleted":{"description":"`410` — le QR code créé avec cette `Idempotency-Key` a été supprimé depuis. Rien n'est\nrecréé : utilisez une nouvelle clé pour une nouvelle création.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ResourceDeletedError"}}}},"PayloadTooLarge":{"description":"`413` — corps de la requête de plus de 64 Kio (65 536 octets) : la requête est refusée,\nrien n'est écrit.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PayloadTooLargeError"}}}}}}}