← Les documents de Broulala

Contrat

Le contrat que lit un système client : l'authentification, l'envoi d'un média, la création d'un job, ce qu'une réponse porte, les codes d'erreur et les limites. C'est la source de vérité de l'implémentation — en cas de contradiction avec n'importe quelle autre page, c'est lui qui a raison.

Contrat d'API — Broulala

Version du document : 2.0 Statut au 20/09/2026 : implémenté et en production, tableau d'acceptation vert à 240/240 (npm run contract:board). Une case, un test : c'est ce tableau qui dit ce qui est tenu, pas cette ligne. Remplace : 1.1. L'API reste /v1 — c'est le document qui change de version, pas l'URL. Portée : ce document définit le contrat générique que tout système client doit respecter pour se connecter à Broulala. Il ne décrit aucune intégration en particulier.

Ce document est la seule source de vérité pour l'implémentation du contrat générique. Toute divergence entre le code et ce document doit être résolue en mettant à jour ce document en premier, puis le code — pas l'inverse.

Ce qui change depuis la 1.1, et pourquoi : voir le §16. À lire avant le reste si vous connaissiez la version précédente.


1. Vue d'ensemble

Broulala fait deux choses, et le contrat les nomme séparément :

  1. Transcrire — un ou plusieurs fichiers audio deviennent une transcription structurée : des segments horodatés.
  2. Générer — un texte plus un template deviennent une sortie, en Markdown ou en JSON selon ce que le template déclare.

Les deux sont chaînables dans un seul job : une entrée audio accompagnée d'un template est transcrite puis passée au template, et le résultat porte les deux. Un client qui ne veut que la transcription omet le template ; un client qui a déjà son texte l'envoie directement.

Le traitement est asynchrone. Le client interroge périodiquement le statut d'un job (polling) ; une intégration peut en plus recevoir un webhook à chaque changement d'état (§6.5), ce qui dispense d'une boucle durable — mais le polling reste le contrat.

Broulala ne produit aucune mise en page (HTML stylé, DOCX, PDF). Le client reçoit du Markdown brut ou du JSON conforme au schéma du template, et gère lui-même le rendu final.

Ce que ce document ne couvre pas : le comportement d'un template_id donné. Le catalogue est celui de la plateforme, le même pour toutes les intégrations — et il est interrogeable par l'API (§4), schéma de sortie et schéma de contexte compris. Ce document fixe l'enveloppe commune : authentification, catalogue, cycle de vie d'un job, capacité de service, formats d'erreur, contraintes non-fonctionnelles.


2. Principes pour l'implémentation

Ces règles s'appliquent à tout système client, qu'il soit implémenté par un agent ou un humain.


3. Authentification


4. Catalogue de templates

GET /v1/templates
Authorization: Bearer <api_key>

Réponse 200 :

{
  "templates": [
    {
      "template_id": "tpl_exemple",
      "version": 4,
      "label": "Libellé lisible",
      "input_kinds": ["media", "transcript"],
      "languages": ["fr"],
      "output": {
        "kind": "json",
        "schema": {
          "type": "object",
          "required": ["items"],
          "properties": {
            "items": {
              "type": "array",
              "items": {
                "type": "object",
                "required": ["source", "summary"],
                "properties": {
                  "source": { "type": "segment_ref" },
                  "summary": { "type": "string" }
                }
              }
            }
          }
        }
      },
      "context_schema": {
        "type": "object",
        "required": ["title"],
        "properties": {
          "title": { "type": "string" },
          "date": { "type": "string", "format": "date" }
        }
      }
    }
  ]
}

4.1 Le type segment_ref

Extension réservée de JSON Schema, valide uniquement dans output.schema. Une valeur de ce type, telle qu'elle est rendue :

{ "segment_id": "seg_0412", "text": "décrite par Hooke", "start_offset": 42, "end_offset": 59 }

Le moteur écrit segment_id, et text s'il désigne un passage plus court que le segment — le texte de l'entrée répété mot pour mot. Il n'écrit jamais d'indice. Les indices sont calculés par la plateforme, qui localise text dans le segment cité.

Pourquoi le moteur ne compte pas. Mesuré le 15/09/2026 sur le cas le plus facile disponible — une phrase courte, un mot évident, la consigne de compter depuis zéro : le modèle rend la bonne longueur à la mauvaise position. Ce n'est pas une question de taille de modèle ni de formulation ; un modèle voit des jetons, pas des caractères. Un template qui exigeait des indices du moteur produisait donc des citations conformes au schéma, résolvant dans les bornes, et désignant quatre caractères de bruit. Répéter un texte est en revanche ce qu'un modèle fait bien, et localiser un texte est ce qu'une machine fait exactement : chacun sa moitié.

4.2 D'où viennent les valeurs de context

Le contrat d'administration définit une ressource Context : un jeu de valeurs enregistré, nommé et versionné, qu'un humain modifie dans une interface. Elle n'est pas une abstraction supérieure du champ context — elle en est une sauvegarde. Le primitif est l'objet passé au job, et c'est lui, et lui seul, qui atteint le prompt.

Un système client construit donc son context à chaque appel, depuis son propre magasin. C'est la voie normale, et la seule en v1.

Broulala n'enregistre aucun contexte réutilisable, et un système client ne peut donc pas en relire un. Il a existé pour cela une ressource Context côté administration ; elle a été retirée le 18/09/2026 parce que rien ne la lisait — et parce qu'un contexte tient par définition à un appel, pas à une organisation : le titre d'un cours change à chaque cours. Ce qui la remplace côté administration est la relecture d'un appel qui a marché.

Le jour où un client sans magasin à lui voudra un contexte hébergé, la forme sera un context_id sur le job, résolu côté serveur — mutuellement exclusif avec context, avec context_id et context_version rendus dans result pour que la provenance tienne. Jamais une lecture qui traverse la frontière d'authentification : /admin/v1/* exige un token de session, une clé d'intégration y est refusée par construction, et percer cette frontière coûterait beaucoup plus que le confort gagné.

C'est hors périmètre v1 (§15).


5. Entrées

Une entrée est soit un ou plusieurs médias, soit du texte, soit une transcription structurée.

5.1 Médias — envoyer en tus

L'envoi se fait en tus 1.0.0, et c'est la seule façon d'envoyer un média. Décision B10 : un corps unique de 150 Mo ne traverse pas le reverse-proxy qui protège la plateforme — il revient en 413, émis par le proxy et non par elle. tus découpe, donc aucune requête ne s'approche de ce plafond. Et il reprend : un transfert interrompu repart de son décalage au lieu de tout renvoyer.

Ce que tus ne fait pas, et qu'il ne faut pas promettre : continuer quand la page qui l'a lancé se ferme. Il rend la reprise possible, pas automatique.

Créer l'envoi

POST /v1/uploads
Authorization: Bearer <api_key>
Tus-Resumable: 1.0.0
Upload-Length: 157286400
Upload-Metadata: content_type YXVkaW8veC1tNGE=

Réponse 201, sans corps :

Tus-Resumable: 1.0.0
Location: /v1/uploads/up_8f3a1c2e
Upload-Expires: Fri, 18 Sep 2026 14:30:00 GMT

Location est relatif, et c'est délibéré. tus 1.0.0 l'autorise et tout client conforme le résout contre l'URL de la requête. Une URL absolue demanderait à la plateforme de connaître sa propre adresse publique — or elle est derrière un reverse-proxy qui est la propriété de l'exploitant, et une adresse configurée à la main est une adresse qui sera fausse un jour, au moment d'un changement de domaine que personne n'aura relié à ce fichier-ci. Ce document montrait une URL absolue jusqu'au 18/09/2026 ; c'est le document qui avait tort.

Envoyer les octets

PATCH /v1/uploads/up_8f3a1c2e
Authorization: Bearer <api_key>
Tus-Resumable: 1.0.0
Upload-Offset: 0
Content-Type: application/offset+octet-stream

<octets>

Réponse 204, avec le nouveau décalage :

Tus-Resumable: 1.0.0
Upload-Offset: 8388608

Reprendre

HEAD /v1/uploads/up_8f3a1c2e
Authorization: Bearer <api_key>
Tus-Resumable: 1.0.0

Réponse 200 :

Tus-Resumable: 1.0.0
Upload-Offset: 41943040
Upload-Length: 157286400
Cache-Control: no-store

Reprendre, c'est lire ce décalage et poursuivre le PATCH à partir de là. Un client qui perd sa connexion, ferme son onglet ou redémarre n'a rien d'autre à retenir que l'URL.

Ce que le serveur annonce

OPTIONS /v1/uploads
→ 204
Tus-Resumable: 1.0.0
Tus-Version: 1.0.0
Tus-Extension: creation,expiration,termination
Tus-Max-Size: 500000000

Les deux durées, inchangées dans leur esprit

5.2 Les trois formes d'entrée

Un ou plusieurs médias. L'ordre du tableau est celui de la jonction, et il n'est jamais redéduit par la plateforme — ni par nom, ni par date.

{ "type": "media", "media": ["up_8f3a1c2e", "up_d40b71aa"] }

À plusieurs fichiers, la jonction se fait en copie de flux, sans ré-encodage. Les pistes doivent donc partager codec, fréquence d'échantillonnage et nombre de canaux. La plateforme les sonde avant de joindre et refuse en nommant l'indice fautif (MEDIA_INCOMPATIBLE, details.index) : l'échec d'un démultiplexeur ne dit pas quelle entrée a divergé, et un client qui vient d'envoyer quatre cents mégaoctets a le droit de savoir lequel retirer. Ré-encoder à la place dégraderait en silence un enregistrement confié.

Du texte brut. Aucune citation n'est possible : un template dont le schéma contient un segment_ref refuse cette entrée (INPUT_NOT_ANCHORABLE).

{ "type": "text", "text": "…" }

Une transcription structurée. La forme est exactement celle que result.transcript rend, de sorte qu'une sortie de Broulala se réinjecte sans transformation — éventuellement après que le client l'a corrigée.

{
  "type": "transcript",
  "segments": [
    { "id": "seg_0001", "start_ms": 0,    "end_ms": 4120, "text": "…", "speaker": "speaker_1" },
    { "id": "seg_0002", "start_ms": 4120, "end_ms": 9880, "text": "…", "speaker": "speaker_2" }
  ]
}

6. Jobs

6.1 Créer un job

POST /v1/jobs
Authorization: Bearer <api_key>
Idempotency-Key: <clé unique générée par le client>
Content-Type: application/json
{
  "input": { "type": "media", "media": ["up_8f3a1c2e"] },
  "language": "fr",
  "transcription": {
    "vocabulary": ["Sarr-Delaunay", "hémostase", "taux directeur"],
    "diarize": true,
    "max_speakers": 7
  },
  "template_id": "tpl_exemple",
  "template_version": 4,
  "context": { "title": "…", "date": "2026-09-08" },
  "service": { "on_degraded": "proceed", "max_wait_seconds": 1800 }
}
ChampObligatoireRôle
inputoui§5.2
languagenonLa langue de ce qui est fourni : celle qui est parlée dans le média, ou celle du texte d'une entrée transcript. fr en v1, seule valeur, fr par défaut. Le champ existe pour qu'une seconde langue ne soit pas un changement de forme. Pas de valeur auto : une détection qui se trompe produit une transcription illisible en silence, et « rien n'est inventé » (§2) vaut ici comme ailleurs. Le jour où la détection arrivera, elle devra dire dans le résultat ce qu'elle a détecté.
transcription.diarizenonSéparer les locuteurs — « qui parle, et quand ». Absent par défaut, parce que c'est du temps de calcul que personne ne doit payer sans l'avoir demandé : séparer les voix est une seconde passe sur l'enregistrement. Le modèle, lui, est chargé par la machine à son démarrage quel que soit le job — c'est une propriété du moteur, elle est à la charge de la plateforme et jamais à la vôtre. Les locuteurs sont anonymes : la plateforme dit que ces plages-ci sont la même voix, jamais de qui il s'agit. Ignoré si l'entrée n'est pas un média.
transcription.min_speakers, max_speakersnonBornes, quand on connaît le nombre de personnes dans la pièce — un conseil d'administration à sept, un entretien à deux. Sans effet si diarize est absent.
transcription.vocabularynonListe de termes attendus, donnée au moteur de transcription : les noms propres et le jargon du domaine du client, quel qu'il soit — l'exemple ci-dessus en mélange trois exprès, parce que l'enveloppe n'en connaît aucun. Des mots, jamais des phrases : une consigne rédigée ressort dans la transcription quand l'enregistrement est calme. 200 entrées au plus, 64 caractères chacune. Ignoré si l'entrée n'est pas un média.
template_idnonAbsent, le job transcrit et s'arrête là.
template_versionnonAbsent, la version courante. Épinglez-la quand vous enregistrez la provenance : sinon une modification du template entre la soumission et la lecture rend votre trace fausse.
contextselon templateValidé contre context_schema. Donnée, jamais instruction (§2).
servicenon§7. Par défaut { "on_degraded": "proceed" }.

Réponse 201 :

{
  "job_id": "job_5b7e9d21",
  "status": "queued",
  "created_at": "2026-09-09T14:16:02Z"
}

Erreurs propres à cet appel : UPLOAD_NOT_FOUND, UPLOAD_EXPIRED, MEDIA_INCOMPATIBLE, INVALID_TEMPLATE_ID, TEMPLATE_VERSION_NOT_FOUND, CONTEXT_INVALID, INPUT_NOT_ANCHORABLE, DUPLICATE_SEGMENT_ID, INPUT_TOO_LARGE, IDEMPOTENCY_KEY_REUSED.

6.2 Consulter un job

GET /v1/jobs/{job_id}
Authorization: Bearer <api_key>

En cours :

{
  "job_id": "job_5b7e9d21",
  "status": "processing",
  "stage": "transcribing",
  "created_at": "2026-09-09T14:16:02Z",
  "started_at": "2026-09-09T14:16:40Z",
  "updated_at": "2026-09-09T14:17:30Z",
  "estimated_completion_at": "2026-09-09T14:23:00Z",
  "service": { "mode": "full", "waited_seconds": 38 },
  "result": null
}

Terminé :

{
  "job_id": "job_5b7e9d21",
  "status": "done",
  "stage": null,
  "created_at": "2026-09-09T14:16:02Z",
  "started_at": "2026-09-09T14:16:40Z",
  "updated_at": "2026-09-09T14:24:11Z",
  "estimated_completion_at": null,
  "service": { "mode": "full", "waited_seconds": 38 },
  "result": {
    "transcript": {
      "language": "fr",
      "duration_ms": 5423000,
      "segments": [
        { "id": "seg_0001", "start_ms": 0, "end_ms": 4120, "text": "…" }
      ]
    },
    "output": {
      "template_id": "tpl_exemple",
      "template_version": 4,
      "kind": "json",
      "data": {
        "items": [
          {
            "source": {
              "segment_id": "seg_0412",
              "text": "décrite par Hooke",
              "start_offset": 42,
              "end_offset": 59
            },
            "summary": "…"
          }
        ]
      }
    },
    "engine": {
      "transcription_model": "whisperx-large-v3",
      "generation_model": "qwen2.5-14b-instruct-q4_k_m-00001-of-00003.gguf@b466e1f8c07172155743e8e1307507d8a4f91fbd",
      "mode": "full"
    },
    "warnings": []
  }
}

En erreur :

{
  "job_id": "job_5b7e9d21",
  "status": "error",
  "stage": null,
  "created_at": "2026-09-09T14:16:02Z",
  "updated_at": "2026-09-09T14:18:45Z",
  "result": null,
  "error": {
    "code": "PROCESSING_FAILED",
    "message": "Audio inexploitable : durée détectée de 0 seconde."
  }
}

404 JOB_NOT_FOUND si le job_id n'existe pas ou n'appartient pas à la clé employée. Les deux cas répondent identiquement : l'existence d'un job d'un autre client n'est pas une information.

6.3 Supprimer un job

DELETE /v1/jobs/{job_id}
Authorization: Bearer <api_key>

Réponse 204, sans corps. Supprime immédiatement et définitivement tout le contenu de l'appel : le média, le texte ou la transcription fournis en entrée, le context, la transcription produite, la sortie, et les termes d'un vocabulary. Idempotent : un appel déjà vidé, ou inconnu de cette clé, répond 204 aussi.

Cet appel existe parce que la rétention automatique du §14 ne suffit pas à un client qui répond lui-même d'une obligation d'effacement : quand il supprime chez lui, il doit pouvoir supprimer ici le même jour, pas trente jours plus tard.

Un job processing est annulé, puis vidé.

Ce qui n'est pas supprimé, et pourquoi — décision B25, 21/09/2026. La ligne de l'appel demeure : son identifiant, ses horodatages, son état, ses durées, le moteur qui a répondu, le template employé, le code d'erreur, le nombre de mots, le nombre de termes du vocabulaire. C'est la trace d'un travail que la plateforme a exécuté et facturé, et elle ne porte aucune donnée de vos utilisateurs finaux — la plateforme n'en connaît aucun (§2), n'accepte aucun nom de fichier (§5.1) et ne garde plus les noms propres d'un vocabulaire.

Cette ligne est ce qu'un administrateur relit six mois plus tard dans le journal des appels, et ce qui répond à une contestation de facture. Un client ne peut pas effacer la preuve d'un travail qu'il a commandé ; il peut effacer tout ce que ce travail a lu et produit, ce qui est l'objet de son obligation et non de la vôtre.

GET /v1/jobs/{job_id} répond donc 200 après cet appel, et non 404 : le job a eu lieu. Il rend result: null — exactement la forme qu'il prend de lui-même trente jours après done (§14), donc aucun client n'a de cas nouveau à traiter. Jusqu'au 21/09/2026 il rendait 404, et la ligne partait avec.

6.4 Polling

6.5 Webhooks

Le polling du §6.4 reste le contrat. Une intégration peut en plus recevoir une webhook à chaque changement de status, ce qui la dispense de tourner en boucle.

C'est une optimisation, jamais le seul chemin. Un client dont le destinataire est tombé pendant une heure doit retrouver ses jobs ; un client qui ignore complètement les webhooks doit fonctionner à l'identique. Une implémentation qui ne va chercher un résultat que lorsqu'un webhook arrive perd un job à la première livraison manquée, et la perd en silence.

Où l'adresse est déclarée

Sur l'intégration, côté administration, et jamais dans un job. L'URL et le secret de signature sont posés par un humain muni d'un token de session ; l'API d'intégration ne permet ni de les lire, ni de les écrire, ni de les surcharger par requête.

Ce n'est pas une commodité, c'est ce qui supprime la surface SSRF : appeler veut dire que la plateforme émet une requête sortante vers une adresse qu'on lui a donnée. Tant que cette adresse vient de la configuration et non d'une saisie, il n'y a rien à filtrer — un administrateur qui déclare http://app-cliente:3000/… sur un réseau de conteneurs l'a fait exprès, et c'est un déploiement légitime que n'importe quel filtre d'adresses privées aurait refusé.

Ce qui est envoyé

POST https://app.exemple.fr/broulala/events
Content-Type: application/json
Broulala-Event-Id: evt_9c1a4f20
Broulala-Signature: t=1757683451,v1=3f8a…

{
  "event_id": "evt_9c1a4f20",
  "type": "job.status_changed",
  "created_at": "2026-09-12T14:24:11Z",
  "data": {
    "job_id": "job_5b7e9d21",
    "status": "done",
    "template_id": "tpl_exemple"
  }
}

Signature

Broulala-Signature: t=<timestamp unix>,v1=<hex> où v1 est un HMAC-SHA256, clé = le secret de l'intégration, message = "{t}.{corps brut}".

Reprises, ordre, idempotence


7. Capacité de service

La plateforme fait tourner ses moteurs sur des machines qu'elle lève et couche. Il arrive qu'elle n'en obtienne pas, et elle bascule alors sur un chemin dégradé : le même travail, beaucoup plus lent, parfois avec un moteur plus petit. Ce fait est exposé, pour trois raisons : le client doit pouvoir prévenir, décider s'il attend, et tenir un échantillon de durées qui ne mêle pas deux populations.

degraded veut dire : transcription seulement. Le chemin dégradé est un moteur de transcription qui tourne sur la machine de la plateforme, sans carte graphique — beaucoup plus lent, même sortie. La génération n'a pas d'équivalent : le modèle ne tient pas sur cette machine, et il n'existe pas de version plus petite qui rendrait le même résultat. Un job qui demande une sortie de template a donc besoin que serves contienne generation — et non que mode vaille full, ce que les deux axes ci-dessous séparent précisément. Mais il n'en a besoin qu'au moment de générer : un job média est transcrit d'abord, et il n'attend la génération qu'une fois sa transcription faite et conservée. Le §7.2 dit ce qu'il advient de lui en attendant.

7.1 Lire l'état

GET /v1/capacity
Authorization: Bearer <api_key>
{
  "mode": "degraded",
  "serves": ["transcription"],
  "mode_until": "2026-09-12T15:04:00Z",
  "degraded_since": "2026-09-12T13:58:00Z",
  "queue_depth": 3,
  "observed_at": "2026-09-12T14:32:11Z"
}
ChampSens
modefull, degraded ou unavailable
servesCe que la plateforme accepte maintenant : transcription, generation, les deux, ou aucun. Additif, ajouté le 18/09/2026 — voir ci-dessous.
mode_untilUn bail, pas un drapeau : le mode annoncé vaut jusque-là. Une valeur dans le passé se lit « on ne sait pas », jamais « c'est toujours vrai ». Un superviseur arrêté cesse donc de promettre au lieu de mentir.
degraded_sinceDepuis quand, ou null. C'est ce qui distingue un creux d'une panne.
queue_depthJobs en attente, toutes clés confondues
observed_atQuand cette ligne a été écrite, ou null — voir ci-dessous

unavailable veut dire qu'aucun moteur n'est joignable, pas même dégradé. Un client l'affiche, il ne le contourne pas.

serves dit ce qui est servi, jamais ce qui tourne. Vous n'apprendrez pas ici combien de machines la plateforme loue ni lesquelles : vous apprenez si un job sera pris. C'est la seule chose dont vous ayez besoin, et c'est aussi la seule que la plateforme puisse promettre sans vous raconter sa forme interne.

servesCe que vous pouvez envoyer
["transcription", "generation"]tout
["transcription"]un job sans template_id. Un job qui en porte un est retenu, pas refusé
["generation"]un job dont l'entrée est déjà text ou transcript
[]rien n'est pris ; c'est ce que mode: "unavailable" dit aussi

Pourquoi un champ, alors que mode suffisait. Il suffisait tant que la génération tournait sur la machine de la transcription : degraded se lisait « transcription seulement », et un seul mot disait tout d'un seul axe. Les deux moteurs vivent désormais sur des machines distinctes, qui peuvent manquer indépendamment — donc « génération disponible, transcription absente » est devenu un état réel, et le mot n'existe pas pour le dire. Ce n'est pas la forme interne de la plateforme qui déborde ici : c'est un fait que vous devez connaître pour afficher « cette fiche est indisponible » au lieu de l'apprendre six heures plus tard par un CAPACITY_UNAVAILABLE. Décision B15.

Les deux champs sont orthogonaux, et c'est pour ça qu'il en faut deux. mode n'a jamais été un axe de disponibilité générale : c'est la qualité du chemin de transcription — full la machine louée, degraded le processeur de la plateforme, unavailable ni l'un ni l'autre. serves porte l'autre axe, celui des services qui existent. Aucune combinaison n'est contradictoire :

modeservesce que ça dit
full["transcription"]une carte pour transcrire, pas de génération
full["transcription", "generation"]tout, et vite
degraded["transcription"]notre processeur seulement, beaucoup plus lent
unavailable["generation"]aucune transcription, mais une fiche peut se rédiger
unavailable[]rien n'est pris

Un client qui ignore serves se comporte exactement comme avant, et cette fois sans réserve : mode n'a pas changé de sens, donc rien de ce qu'il en déduisait n'a bougé.

Une première rédaction posait que « full implique les deux services ». Elle aurait fait de mode un axe de disponibilité générale, et mode route : le worker choisit le moteur de transcription sur lui. Une plateforme qui transcrit sur carte louée sans machine de génération aurait dû s'annoncer degraded, et se serait donc transcrite elle-même sur son propre processeur — cent cinq minutes au lieu de trois, pendant qu'une carte allumée et facturée ne faisait rien. Les deux axes sont séparés pour que cela ne puisse pas se dire.

Ce que rend un bail périmé, et ce que rend l'absence de ligne. Les deux disent « on ne sait pas », et la plateforme applique la règle elle-même plutôt que de la laisser au client :

Cet appel n'est pas décompté du quota du §13 et peut être interrogé une fois par minute.

7.2 Décider, par job

"service": { "on_degraded": "proceed", "max_wait_seconds": 1800 }

Et un job média à template attend en deux fois, depuis le 18/09/2026. Il part dès qu'un moteur de transcription est disponible ; sa transcription faite et conservée, il retourne en file — queued, stage: "generating" — jusqu'à ce que serves contienne generation. max_wait_seconds borne chacune des deux attentes, et service.waited_seconds les additionne.

Pourquoi ce n'est pas un retard qu'on vous ajoute. Auparavant ce job était retenu entier tant que la génération n'était pas servie, et la plateforme levait donc les deux machines à l'admission — dont celle de la génération, qui restait allumée à ne rien faire pendant toute la transcription. Mesuré le 18/09/2026 sur deux heures et demie d'audio : quinze minutes de carte pour vingt-trois secondes de travail. Le job finit maintenant dans le même temps quand tout est disponible, et il n'attend séparément que lorsque la génération manque — auquel cas il a au moins sa transcription d'avance.

Le choix se fait par job et non par clé, parce qu'un même client peut légitimement répondre différemment selon ce qu'il demande.

7.3 Savoir sous quel mode ça a tourné

service.mode dans le job rend le mode réellement employé (full ou degraded), et service.waited_seconds le temps passé en waiting_capacity. Un client qui estime des durées à partir de son historique doit segmenter son échantillon là-dessus : trois minutes et cent cinq minutes pour le même audio sont deux populations, et les mêler fait mentir une barre de progression dans les deux sens.


8. Machine à états

queued → processing → done
   │                ↘ error
   ↘ error

9. Rappel des invariants de composition du prompt

Cette section ne décrit pas un endpoint : elle décrit une garantie que les clients ont le droit d'invoquer.

  1. Les invariants de la plateforme sont en tête du message système, et aucun template ne peut les retirer.
  2. Les instructions du template suivent.
  3. context arrive en dernier, présenté comme de la donnée, jamais comme une suite d'instructions.

Un context qui contiendrait « ignore les consignes précédentes » est donc un texte que le modèle lit, pas une consigne qu'il reçoit. C'est la position qui rend l'attaque sans objet, et non un filtre — un filtre se contourne, un ordre de composition non.


10. Enums

EnumValeurs
statusqueued, processing, done, error
stagewaiting_capacity, transcribing, generating, ou null
input.typemedia, text, transcript
output.kindmarkdown, json
service.on_degradedwait, proceed
capacity.mode / service.modefull, degraded, unavailable (unavailable seulement sur la capacité)
type (webhook)job.status_changed
languagefr
content_typeaudio/mpeg, audio/wav, audio/mp4, audio/x-m4a, video/mp4
template_idle catalogue de la plateforme, le même pour toutes les clés — lisible par GET /v1/templates

11. Erreurs

11.1 Codes

Toute erreur suit cette enveloppe, quel que soit l'endpoint :

{
  "error": {
    "code": "STRING_CODE",
    "message": "Message lisible, non contractuel — ne pas parser.",
    "details": {},
    "request_id": "req_a1b2c3"
  }
}

details est toujours présent, éventuellement vide.

CodeHTTPSignification
INVALID_API_KEY401Clé absente, inconnue, désactivée ou révoquée — les quatre répondent pareil
QUOTA_EXCEEDED403Volume mensuel épuisé — details.resets_at, details.limit_minutes
BUDGET_EXCEEDED403Plafond de sécurité en argent atteint — details.resets_at
ORGANISATION_SUSPENDED403L'organisation est suspendue : création d'envoi et de job refusées, lecture et suppression toujours ouvertes
UPLOAD_NOT_FOUND404upload_id inconnu, ou fichier jamais reçu
UPLOAD_EXPIRED410Envoi non terminé au-delà d'Upload-Expires, ou job créé plus de 24 h après la complétion
UNSUPPORTED_MEDIA_TYPE415content_type fourni hors liste, ou octets dont le type réel n'est pas supporté
PAYLOAD_TOO_LARGE413Fichier au-delà du plafond par fichier
UPLOAD_OFFSET_CONFLICT409Le Upload-Offset d'un PATCH ne correspond pas à ce qui a été reçu — reprenez depuis le HEAD
UPLOAD_INCOMPLETE409Un job référence un envoi dont le décalage n'a pas atteint sa longueur
INPUT_TOO_LARGE413Entrée du job au-delà du plafond de job (§14)
MEDIA_INCOMPATIBLE422Un média ne peut être joint aux autres — details.index nomme lequel, details.reason dit quoi (codec, sample_rate, channels)
DUPLICATE_SEGMENT_ID422Deux segments d'entrée portent le même id — details.id
INPUT_NOT_ANCHORABLE422Le template exige des citations, l'entrée ne porte pas de segments
INVALID_TEMPLATE_ID422Aucun template de ce nom au catalogue, ou aucune version publiée
LANGUAGE_NOT_SUPPORTED422Le language du job n'est pas dans les languages du template — details.supported les liste
TEMPLATE_VERSION_NOT_FOUND422Version demandée inexistante
CONTEXT_INVALID422context ne respecte pas le context_schema — details.errors porte les chemins fautifs
IDEMPOTENCY_KEY_REUSED409Même clé, corps différent, dans les 24 h
JOB_NOT_FOUND404job_id inconnu ou hors de cette clé
RATE_LIMITED429Quota dépassé — voir Retry-After
INTERNAL_ERROR500Erreur imprévue — le client peut retenter avec la même Idempotency-Key
PROCESSING_FAILED200, dans errorÉchec métier : audio illisible, silence total, aucune parole
PROCESSING_TIMEOUT200, dans errorDépassement des 2 h de traitement
CAPACITY_UNAVAILABLE200, dans errormax_wait_seconds épuisé sur un job retenu — parce qu'il avait dit wait, ou parce que ce dont il a besoin n'est pas servi (§7.2)
OUTPUT_INVALID200, dans errorLe moteur n'a pas produit une sortie exploitable — details.reason. Un seul tirage : le décodage est déterministe (temperature: 0), donc recommencer rendrait la même sortie

PROCESSING_FAILED n'est jamais silencieux sur une transcription vide. Un moteur qui rend zéro segment sur un audio non nul est un échec, pas un résultat : le message dit si l'enregistrement est illisible ou sans parole.

OUTPUT_INVALID porte details.reason, parce que trois pannes très différentes se cachaient derrière un seul code et que seule une phrase française les séparait — or le message n'est pas stable, et un client ne peut pas router dessus.

details.reasonCe qui s'est passéCe que vous pouvez en faire
schemaLa sortie ne respecte pas le schéma du templateLe template est en cause, pas votre appel. Signalez-le
truncatedLe moteur a écrit jusqu'à son plafond de jetons et s'est arrêté au milieuLe template produit trop pour ce qu'il demande. Signalez-le
citationsAucune citation ne tenait — voir §4.1Recommencer ne servira à rien sur la même entrée

Les trois disent la même chose sur la conduite à tenir — ne pas retenter — et pas la même sur qui répare. C'est ce que le code seul ne pouvait pas dire.

OUTPUT_INVALID couvre aussi une sortie tronquée. Un budget de jetons épuisé rend un texte qui ressemble à un résultat en s'arrêtant au milieu d'une phrase ; il est traité comme un échec et jamais rendu.

11.2 Codes d'avertissement

CodeSens
LOW_AUDIO_QUALITYSignal dégradé sur tout ou partie de l'enregistrement
PARTIAL_SILENCELongues plages sans parole
UNCERTAIN_DIARIZATIONLe regroupement des voix est peu sûr sur tout ou partie de l'enregistrement — details.from_ms / details.to_ms
VOCABULARY_IGNOREDDes entrées de vocabulary ont été écartées (trop nombreuses, ou trop longues) — details.count
CONTEXT_FIELD_UNUSEDUn champ de context n'est employé par aucune version de ce template — signale une fiche technique périmée côté client
CITATIONS_DISCARDEDDes éléments de la sortie citaient un passage introuvable et ont été écartés — details.count, details.reason. Les autres sont intacts et vérifiés

12. Sécurité du contenu


13. Limitation de débit

13.1 Le quota mensuel, qui n'est pas une limitation de débit

La limitation ci-dessus est une question de cadence, par clé, sur une fenêtre courte. Le quota est une question de volume, par organisation, sur un mois. Les deux coexistent et ne se remplacent pas : l'une empêche une rafale, l'autre borne une facture.

Et c'est un disjoncteur, pas une barrière — écrit ici le 21/09/2026 parce que c'était vrai sans être dit. Le plafond est lu à l'admission d'un job et la consommation écrite à sa complétion : plusieurs jobs soumis dans la même fenêtre passent donc tous le contrôle avant qu'aucun n'ait compté, et le dépassement possible vaut nombre de jobs simultanés × coût unitaire. Ce qui le borne est la limitation de cadence ci-dessus, qui plafonne ce nombre.

Une barrière stricte demanderait une réservation, donc une transaction sur le chemin chaud de chaque création — payée par tous les appels pour une précision qui n'intéresse que le dernier avant le plafond. Un disjoncteur protège d'une boucle, pas d'un décompte à l'unité, et c'est ce qu'il est censé faire.

La limitation de cadence, elle, compte en mémoire de processus. Une seconde instance d'API autoriserait donc le double, et un redéploiement oublie la fenêtre en cours. Les deux sont acceptables pour une garde contre les rafales et ne le seraient pas pour un quota — c'est exactement pourquoi le quota ci-dessus n'est pas implémenté ainsi. Il n'y a qu'une instance aujourd'hui ; le jour où il y en a deux, cette ligne est ce qu'il faut relire.

L'unité est la minute d'audio traitée, et pas l'appel. Un job sur dix minutes de réunion et un job sur deux heures de cours diffèrent d'un facteur douze en temps de machine ; compter les appels ferait payer pareil deux choses incomparables, et ne permettrait à personne de prévoir quoi que ce soit.

Et un plafond en argent l'accompagne, qui n'est pas un tarif mais un disjoncteur. Il attrape ce que les minutes ne voient pas : une génération repartant d'une entrée transcript ne consomme aucune minute d'audio et coûte pourtant du calcul. Sans lui, c'est le trou du compteur.

PlafondDépassement
Volumequota_minutes par organisation et par mois403 QUOTA_EXCEEDED
Argentbudget_cents par organisation et par mois403 BUDGET_EXCEEDED

Vous êtes prévenu avant d'être coupé — issue #145, décision B30. Au franchissement de 80 % d'un plafond, les administrateurs de l'organisation reçoivent un courriel : lequel des deux plafonds, où vous en êtes, et quand la période se renouvelle. Une fois par période et par plafond ; une rallonge qui vous ramène sous le seuil réarme l'avertissement.

13.2 Le tarif est une donnée datée, jamais une constante

Le prix changera. S'il vit dans le code et que le coût se recalcule à l'affichage, le modifier réécrit le passé : les 12 € consommés le mois dernier deviennent 15 € parce qu'on a touché une ligne de configuration.

Donc : un tarif porte une date d'entrée en vigueur, et le coût est calculé une fois, au terme du job, puis recopié sur la ligne d'usage avec la référence du tarif appliqué. Une ligne dit ce qui a été facturé ce jour-là, pour toujours. C'est le principe qui fait déjà rapporter à engine le moteur ayant réellement répondu plutôt que celui qui était configuré.

Ce mécanisme n'était pas construit au 18/09/2026, et ce document l'a dit tant que c'était vrai. Il l'est depuis le 24/09/2026 : un tarif se publie, le coût d'un appel est calculé à son terme et recopié sur l'appel et sur la ligne d'usage du jour. Tant qu'aucun tarif n'est en vigueur, cost_cents vaut toujours null — jamais un zéro, qui voudrait dire « gratuit » là où la vérité est « on ne sait pas » (§2.7 de CLAUDE.md).

Deux composantes en v1 : un prix par heure d'audio transcrite et un prix par génération. La seconde pourra devenir un prix par millier de caractères le jour où la mesure le justifiera — le mécanisme ne change pas, seul le contenu d'une ligne de tarif change.

L'assiette est l'audio traité, jamais le temps que la plateforme a mis (décision B28). Le même fichier se transcrit en trois minutes sur une carte louée et en cent cinq sur le chemin dégradé : facturer le temps de traitement reviendrait à vous vendre notre météo, au même résultat. Ce qui est facturé est donc ce que vous avez demandé — la durée de l'enregistrement — et l'attente de capacité n'entre dans aucun décompte.

Le coût d'un appel est arrondi au centime supérieur. Un arrondi au plus proche rend des appels gratuits : vingt secondes à 0,75 €/h font 0,42 centime, donc zéro. Un appel qui a été traité n'a pas coûté zéro, et un compteur qui le dit ment dans le sens qui ne se voit pas.


14. Contraintes non-fonctionnelles

Durée audio maximale4 heures par job, tous médias joints
Taille par fichier500 Mo
Taille par job1 Go, tous médias confondus
Locuteurs distincts par job20
Segments par entrée transcript20 000
Caractères par entrée text ou transcript600 000
Entrées de vocabulary200, de 64 caractères au plus

Rétention

DonnéeDurée
Envoi reçu, non encore référencé par un job24 h après sa création, puis supprimé
Média brutsupprimé immédiatement après done. En cas d'error, conservé 48 h à compter de l'échec, puis supprimé.
Texte, transcription ou contexte fournis en entréesupprimés avec le résultat. En cas d'error, 48 h à compter de l'échec — le même délai que le média, et pour la même raison : reproduire l'incident
Résultat (transcription produite + sortie)30 jours après done, puis purgé. Une transcription faite pour un job qui a échoué ensuite suit le délai de 48 h ci-dessus
Tout le contenu — média, entrée, context, résultat, termes du vocabularyimmédiatement, sur DELETE /v1/jobs/{id}. La ligne de l'appel demeure : voir §6.3
Termes d'un vocabularyavec le reste du contenu. Seul leur nombre subsiste, parce qu'il explique une durée sans nommer personne
Compteurs d'usage journaliers13 mois
Trace d'une livraison de webhook30 jours après qu'elle a abouti ou été abandonnée — la même échéance que le résultat qu'elle annonce. Une livraison encore en attente n'est jamais balayée, quel que soit son âge : c'est la file
Tarifsconservés indéfiniment — une ligne d'usage les référence

Ces durées sont des planchers, pas des instants. Un balayage périodique les applique, donc une donnée dont la durée vient d'expirer peut vivre quelques minutes de plus. Ce qui est promis est qu'elle part, pas la seconde à laquelle elle part — sauf pour DELETE /v1/jobs/{id}, qui supprime tout de suite et dont c'est le sujet.

Le 48 h court depuis l'échec, et pas depuis l'envoi. Écrit ainsi le 18/09/2026 parce que les deux lignes se contredisaient : un envoi expire 24 h après sa création, or le média d'un job qui échoue le lendemain doit encore être là le surlendemain. Dater depuis l'échec fait tenir les deux promesses à la fois — celle qui protège le disque et celle qui permet de reproduire un incident sur le fichier d'origine.

Ce qui survit à la purge, et rien d'autre. Deux choses, et elles répondent à deux questions différentes.

Les compteurs journaliers par organisation — nombre d'appels, secondes d'audio, coût —, jamais un échantillon par appel. Un histogramme sur trente jours a besoin d'intervalles, pas de lignes ; et treize mois couvrent un exercice plus un mois, ce qu'une contestation de facture peut demander. Aucun identifiant de job, aucun paramètre, aucun contenu : la ligne dit combien, pas quoi.

Et la ligne de chaque appel, vidée de son contenu : identifiant, horodatages, état, durées, moteur, template et version, code d'erreur, nombre de mots, nombre de termes de vocabulaire. C'est ce qui permet de répondre à « qu'est-il arrivé à cet appel-là » quand le compteur ne répond qu'à « combien en février ». Elle ne porte aucune donnée de vos utilisateurs finaux, et c'est ce qui autorise sa conservation.

Ce paragraphe disait « les compteurs, et c'est tout ce qui survit » jusqu'au 21/09/2026, et c'était faux depuis le premier jour : le balayage des trente jours a toujours mis les colonnes de contenu à NULL en gardant la ligne. Personne ne s'en était aperçu parce que rien ne lisait ces lignes ; le journal des appels de la console les lit désormais, et la phrase a rencontré son lecteur.

Le client doit récupérer et persister ce qu'il veut garder. La rétention de la plateforme est un délai de grâce, pas un stockage.

Langue : le français, et lui seul, en v1 — pour l'entrée comme pour la sortie. Deux endroits la déclarent et ils ne disent pas la même chose : language sur le job est la langue de ce qu'on fournit, languages sur le template est celle dans laquelle il est écrit. Le résultat rapporte celle qui a servi, dans result.transcript.language.


15. Hors périmètre v1

À ne pas implémenter dans cette itération, même si ça semble naturel de le faire en même temps.


16. Ce qui change depuis la 1.1, et pourquoi

#ChangementRaison
1La transcription est exposée dans resultElle était déjà produite et conservée (§9 de la 1.1 la nommait dans la rétention) mais jamais rendue. Un client dont le produit est la transcription ne pouvait pas exister.
2template_id devient facultatifPermet de ne transcrire que. Sans ça, obtenir un transcript obligeait à demander un document dont on n'a que faire.
3Entrées text et transcriptRégénérer une sortie n'oblige plus à renvoyer l'audio et à tout retranscrire. Surtout, un client peut faire lire au modèle un texte qu'il a corrigé : sans ça, tout le travail de relecture d'un utilisateur final est invisible au moteur.
4Le client fournit ses propres identifiants de segment, et ils sont rendus tels quelsUn client recolle le résultat sur ses objets sans table de correspondance, et la plateforme n'a rien à mémoriser.
5output.kind : markdown ou json avec schémaC'est ce qui permet à une sortie structurée — des questions, des cartes, des lignes de tableau — d'être un template de plus, et non un type de job de plus. Sans ça, la plateforme livre une version à chaque idée de ses clients.
6Type réservé segment_ref, validé par la plateforme« Une sortie doit pouvoir désigner un endroit de son entrée. » Une citation qui pointe nulle part est le mode de défaillance réel, et le client ne peut pas le vérifier à moindre coût.
7GET /v1/templates, avec output.schema et context_schema« Pas d'inférence implicite » devient vérifiable par machine. Un client ne code plus contre un PDF.
8engine dans le résultat : modèles réellement employés, template_versionLe fournisseur ne sait pas quel moteur répondra — seule la réponse le sait. Recopier la configuration inscrirait le nom du gros moteur sur une sortie écrite par le petit.
9GET /v1/capacity et service.on_degradedLe mode dégradé cesse d'être un secret de la plateforme. Le client peut prévenir ses utilisateurs, et décider par job s'il préfère attendre ou accepter — la réponse diffère selon ce qu'il demande.
10service.mode dans le résultatSans lui, un client qui estime des durées mélange deux populations et sa barre de progression ment dans les deux sens.
11DELETE /v1/jobs/{id}Un client soumis à une obligation d'effacement doit pouvoir supprimer ici le jour où il supprime chez lui.
12Plusieurs médias joints par job, avec refus nommant l'indiceUn enregistrement fait en trois fois est un cas ordinaire. Joindre par copie de flux appartient à une plateforme média ; refuser en nommant la partie fautive est ce qui rend le refus réparable.
13transcription.vocabularyLes termes propres à un domaine sont ce sur quoi un moteur de transcription se trompe, et c'est précisément pour eux qu'on enregistre.
14Idempotency-Key avec corps différent → 409Rendre silencieusement l'ancien résultat masquerait une erreur du client.
15stage, et estimated_completion_at nullableDe quoi afficher quelque chose d'honnête, sans jamais inventer un pourcentage.
28Quota mensuel par organisation, en minutes d'audio, avec un plafond en argent en disjoncteur (§13.1), et un tarif daté recopié sur chaque ligne d'usage (§13.2)L'appel est une unité qui ne suit pas le coût. Les minutes le suivent et se prévoient. Le disjoncteur couvre les générations, qui ne consomment pas d'audio. Et un tarif recalculé à l'affichage réécrirait des factures passées à chaque changement de prix.
27Compteurs d'usage journaliers, 13 mois, seule chose qui survit à la purgeUn histogramme a besoin d'intervalles et non d'un échantillon par appel ; et une contestation de facture porte sur un mois, pas sur un job. La ligne dit combien, jamais quoi.
26Une clé peut être désactivée (réversible) ou révoquée (définitive)Couper l'accès d'un client le temps d'un incident ne doit pas obliger à lui en réémettre une. Côté client les deux restent 401 : la nuance renseignerait quelqu'un qui tâtonne.
25ORGANISATION_SUSPENDED (403), et la suspension n'arrête que la consommationUn 401 enverrait un client faire tourner ses clés au lieu d'écrire à son fournisseur. Et retenir ce qu'il a déjà produit ferait d'une mesure de gestion une confiscation — il peut toujours lire et effacer.
24language (job) et languages (template) sont deux champs distinctsIls partageaient un mot pour deux choses : la langue de ce qu'on fournit, et celle dans laquelle un prompt est écrit. Un template français appliqué à un enregistrement anglais doit être refusé par son nom, pas produire une sortie que personne n'a demandée.
16language avec une seule valeurUne seconde langue ne sera pas un changement de forme.
17 bisLe transport est chiffré sauf vers une adresse injoignable depuis l'internet (§3)Un client qui déploie Broulala sur sa propre machine parle en boucle locale ; exiger un certificat là est une cérémonie. C'est l'adresse qui décide, pas le schéma.
17Le type réel d'un média est vérifié sur les octetsUn content_type cru sur parole donne une transcription vide plutôt qu'une erreur, ce qui est le pire des deux.
18Les invariants de composition du prompt (§9) deviennent une garantie écriteUn client a besoin de savoir que ce qu'il met dans context ne peut pas déshabiller un template.
19Le rapport entre le champ context et la ressource Context du contrat d'administration est écrit (§4.2)Les deux documents partageaient un mot pour deux choses. Le champ est le primitif, la ressource en est une sauvegarde éditable par un humain, et aucune clé d'intégration ne peut la relire — ce qui rend context_id la seule forme possible le jour venu.

| 23 | Diarisation : transcription.diarize + bornes, et speaker sur chaque segment | « Qui parle, et quand » est indispensable à un compte-rendu de réunion et inutile à un cours magistral — donc à la demande, jamais par défaut. Le format d'un segment appartient à l'enveloppe : le décider après qu'un client l'a consommé aurait coûté un /v2/. Le locuteur est un identifiant opaque, parce qu'un nom serait une donnée personnelle traversant la frontière sans que la plateforme sache la protéger. | | 23 | L'envoi passe en tus 1.0.0 (§5.1), et remplace l'URL signée + PUT | Un corps unique de 150 Mo est refusé par le reverse-proxy qu'on garde devant la plateforme. En prime, un envoi interrompu reprend au lieu de tout recommencer. Décision B10. | | 22 | expires_at borne le début du PUT, pas la vie de l'objet reçu (24 h) | Une seule durée rendait l'envoi en plusieurs parties impraticable : trois fichiers envoyés l'un après l'autre auraient vu le premier périmer avant l'arrivée du troisième, et la réparation promise par MEDIA_INCOMPATIBLE n'aurait jamais pu fonctionner. | | 21 | filename supprimé de la création d'envoi, content_type rendu facultatif | Personne ne consommait le nom, et c'est de la donnée personnelle qui contredisait l'interdiction de contenu dans les journaux (§12). Le type se décide sur les octets ; le déclarer n'est plus qu'une commodité de refus précoce. La longueur reste requise — Upload-Length depuis le n° 23 — parce que c'est le seul champ qui permette de refuser avant l'envoi. | | 20 | Webhooks (§6.5), déclarés côté administration, sans contenu | Un client hébergé en application web n'a pas de processus durable pour poller ; il ne peut être que réactif. L'adresse vient de la configuration et jamais d'un job, ce qui supprime la surface SSRF au lieu de la filtrer ; et le message ne porte rien, pour qu'une livraison perdue coûte un appel et non une donnée. |

Ce qui n'a pas changé : le polling — qui reste le contrat, le webhook n'étant qu'une optimisation —, l'absence de rendu, la clé unique par intégration, l'absence de multi-tenant, l'absence de relance automatique, les 2 h de timeout, la purge à 30 jours, l'interdiction du contenu dans les journaux.


17. Checklist d'acceptation

Côté Broulala

Côté système client