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 :
- Transcrire — un ou plusieurs fichiers audio deviennent une transcription structurée : des segments horodatés.
- 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.
- Idempotence obligatoire. Toute requête de création (
POST) accepte un headerIdempotency-Key. Une même clé, réutilisée dans les 24 h avec le même corps, renvoie le même résultat sans recréer de ressource. Une même clé avec un corps différent est un409IDEMPOTENCY_KEY_REUSED: rendre silencieusement l'ancien résultat masquerait une erreur du client. - Pas d'inférence implicite. Chaque champ de l'enveloppe est listé ici. Chaque champ de
contextest déclaré par le schéma du template, queGET /v1/templatespublie. - Une sortie doit pouvoir désigner un endroit de son entrée. C'est ce qui sépare un moteur qui rend un document d'un moteur qui rend de la matière exploitable, et c'est la seule chose qu'un client ne peut pas rattraper de son côté. Un client qui envoie des segments identifiés reçoit des citations contre ses identifiants (§5.2, §6.3).
- Le contexte du client est une donnée, jamais une instruction. Le prompt est composé dans cet ordre, et l'ordre est une garantie de la plateforme : invariants plateforme, puis instructions du template, puis
context. Rien de ce qu'un client écrit danscontextne peut retirer une règle du template ou de la plateforme — y compris uncontextqui demande exactement cela. - Rien n'est inventé. Un champ que la plateforme ne sait pas remplir vaut
nullet jamais une approximation. Cela vise nommémentestimated_completion_at(§6.2) : une estimation fausse coûte plus cher au client qu'une estimation absente, parce qu'il l'affichera. - Erreurs structurées et stables. Tout code d'erreur possible est au §11. Un agent ne doit jamais parser un message en langage naturel pour décider d'une action.
- Pas de champ magique. Les enums fermées sont listées (§10). Une valeur hors enum produit un
422explicite, jamais un comportement silencieux. - Versionnement dans l'URL.
/v1/.... Un changement non rétrocompatible donnera lieu à/v2/..., jamais à une modification silencieuse de/v1/. - Un client = une intégration = une clé API. Broulala ne connaît que le système qui l'appelle, jamais les utilisateurs finaux de ce système. Un client qui sert lui-même de nombreux utilisateurs gère cette segmentation de son côté — il n'y a pas de notion de sous-compte.
- Un webhook dit d'aller voir, il n'apporte rien. Un webhook (§6.5) ne porte jamais de contenu, et le polling reste le contrat : une livraison perdue doit coûter un appel, jamais un job.
- Le client est responsable de ce qu'il conserve. Broulala purge (§14). Rien de ce qui doit durer ne doit dépendre de la rétention de la plateforme.
3. Authentification
- Header requis sur toutes les requêtes :
Authorization: Bearer <api_key>. - Une clé API est émise par intégration. Elle porte la portée de son organisation — ses envois, ses jobs, son quota — et pas le catalogue : celui-ci appartient à la plateforme, et toute clé y lit la même chose (§4, décision B8). Il n'y a donc pas de périmètre de templates à négocier à la mise en place ; ce qui borne votre consommation est votre quota.
- Toute requête sans clé valide renvoie
401INVALID_API_KEY. Une clé désactivée et une clé révoquée répondent exactement comme une clé inventée : distinguer les trois renseignerait quelqu'un qui essaie des clés au hasard. La différence entre les deux états n'existe que côté administration — l'une se rouvre, l'autre jamais. - Une organisation suspendue est refusée autrement, et c'est délibéré.
403ORGANISATION_SUSPENDED, nommé, parce que celui qui tient une clé valide n'est pas un inconnu qui tâtonne : c'est un client dont l'exploitant a fermé le compte, et lui rendre401l'enverrait faire tourner ses clés pendant des heures au lieu d'écrire à son fournisseur. - La suspension arrête la consommation, pas la récupération. Créer un envoi ou un job
est refusé ; lire un job, son résultat et le catalogue reste ouvert, et
DELETEaussi. Un client doit pouvoir récupérer et effacer ce qu'il a déjà produit — le lui retenir ferait d'une mesure de gestion une confiscation. Les jobs déjà en cours vont à leur terme : les arrêter gaspillerait un calcul déjà dépensé pour ne rien rendre. - Transport : chiffré, sauf vers une adresse que l'internet ne peut pas joindre. Une
requête en clair vers un nom public est rejetée (
426 Upgrade Required, ou refus au niveau du reverse proxy) ; du clair vers une boucle locale ou une adresse privée est accepté, parce qu'un client qui héberge Broulala à côté de lui n'a rien à protéger d'un tuyau qui ne sort pas de la machine. C'est l'adresse qui décide, pas le schéma : affirmer « le schéma est https » est plus étroit que ce qu'une configuration peut réellement prouver, et monter un certificat pour parler à127.0.0.1est une cérémonie qui n'achète rien.
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" }
}
}
}
]
}
GET /v1/templates/{template_id}rend une seule entrée, et accepte?version=pour lire une version passée.output.kindvautmarkdownoujson. Enmarkdown,schemaest absent. Enjson,schemaest un JSON Schema (draft 2020-12) que toute sortie respecte.languagesdit dans quelles langues ce template est écrit et réglé. Un job dont lelanguagen'y figure pas est refusé (422LANGUAGE_NOT_SUPPORTED) plutôt que de produire une sortie française à partir d'un enregistrement anglais — un prompt est écrit dans une langue, et il n'est pas transposable par bonne volonté.input_kindsdit ce que le template sait consommer. Un template dont le schéma contient unsegment_refne peut pas déclarertext: il exige une entrée qui porte des segments.context_schemaest validé à la création du job. C'est ce qui rend « pas d'inférence implicite » vérifiable par machine plutôt que promis par une fiche technique.- Le catalogue est en lecture seule pour un client : créer ou modifier un template se fait côté administration (voir le contrat d'administration).
- Le catalogue est celui de la plateforme, et il n'appartient à aucune intégration. Un
template n'est rattaché à aucune organisation : c'est une bibliothèque tenue par le super
administrateur, et toute clé y lit la même chose. Rien ne relie une organisation à un
template : elle le lit, et elle l'emploie en le nommant dans un job. Ce qu'elle apporte de
son côté est le
contextdu job, transmis par le client à chaque appel (§4.2) — il ne s'enregistre nulle part chez Broulala.
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é.
segment_iddésigne un segment de l'entrée.text, facultatif, est un passage de ce segment. Absent, la citation désigne le segment entier et ne porte pas d'indice.start_offset/end_offsetsont des indices de caractères dans le texte du segment, en points de code Unicode,endexclu. Ils sont rendus, jamais reçus : une sortie de moteur qui en porte est refusée comme n'importe quel champ inconnu.La plateforme valide toute référence avant de rendre le job. Un
segment_idinconnu, ou untextintrouvable dans le segment cité, est une citation qui ne tient pas — et l'élément qui la porte est écarté de la sortie, avec unCITATIONS_DISCARDEDqui dit combien et pourquoi. Un jobdonene contient jamais de citation qui ne pointe nulle part, ni de citation qui prétend reprendre l'entrée sans la reprendre.Écarté, et non fatal — depuis le 18/09/2026. Jusque-là une seule citation fautive faisait échouer le job entier. La garantie est la même — ce qui vous atteint est vérifié — et elle se tient aussi bien en retirant l'élément fautif qu'en retirant les onze autres avec lui. Mesuré : un moteur en rate régulièrement un sur douze, et le tout-ou-rien ne se dégrade pas, il s'effondre — à un raté sur dix, douze citations donnent 72 % d'échec. Il punissait donc le plus durement les templates qui citent le plus.
Si tout est écarté, c'est
OUTPUT_INVALID. Une sortie dont aucune citation ne tient n'est pas une sortie appauvrie, c'est une sortie fausse — et un tableau vide rendu endoneserait le silence que ce contrat refuse partout.textne veut dire « citation » qu'à l'intérieur d'unsegment_ref. Un template reste libre d'appelertextun champ ordinaire — une définition, un intitulé, un résumé — et la plateforme n'y touche pas : c'estsegment_idqui fait une citation, et rien d'autre.Un
textqui figure plusieurs fois dans son segment désigne sa première occurrence. La citation reste exacte — c'est le texte de l'entrée — et seul son emplacement est alors arbitraire.textest borné par le plus long segment de l'entrée, et la borne est posée dans la grammaire qui contraint le moteur. Une chaîne plus longue que le plus long segment ne peut être la citation d'aucun d'eux : la borne n'est donc pas un réglage, c'est un fait de votre entrée, et elle ne peut refuser aucune citation légitime. Elle existe parce qu'un champ libre non borné laisse un moteur écrire jusqu'à son plafond de jetons et rendre du JSON inachevé — mesuré le 18/09/2026, douze minutes de génération pour une sortie tronquée.Deux conséquences à connaître. La grammaire dépend donc de l'entrée : deux jobs sur la même version de template n'envoient pas la même, ce qui ne change rien à ce qu'une sortie correcte peut être, puisque la borne ne retire que des chaînes qui ne pouvaient pas être des citations. Et elle borne une citation, pas la sortie entière : ce qui borne la sortie est le produit de cette borne par le
maxItemsdu template, donc un template sansmaxItemsreste capable de déborder.Un
segment_idque l'entrée ne porte pas ne peut plus être écrit, et c'est le pendant de la borne ci-dessus. La plateforme nomme vos segments pour le moteur —s1,s2, … dans l'ordre de lecture — et la grammaire n'admet que ces noms-là, donc un identifiant inventé est aussi inécrivable qu'un indice l'est depuis le 15/09/2026. Lesegment_idqui vous est rendu reste le vôtre : le renommage vit le temps d'un appel au moteur et ne franchit jamais la frontière de l'API.Ce que ça change pour vous, et c'est la seule chose qui se voit :
CITATIONS_DISCARDEDcesse de citer des identifiants qui n'existent pas. Les citations écartées qui restent sont celles dont letextne se retrouve pas dans le segment nommé — une reformulation, pas une invention.Le format de vos identifiants cesse donc de compter. Il comptait : mesuré le 27/09/2026 sur une entrée de 392 segments dont les identifiants partageaient leurs 23 premiers caractères, un moteur en inventait la fin et jusqu'à la moitié des citations tombaient. Vous n'avez plus à les raccourcir avant de nous les envoyer.
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.
Upload-Lengthest requis et joue le rôle qu'avaitsize_bytes: refuser un fichier hors plafond avant que quelqu'un passe une demi-heure à l'envoyer. Au-delà de 500 000 000 octets,413PAYLOAD_TOO_LARGE.Upload-Metadataacceptecontent_type, et rien d'autre. Fourni, il permet un refus précoce ; absent, il ne manque rien. Il n'est jamais cru : le type réel se décide sur les octets, à la fin de l'envoi, et c'est ce contrôle-là qui fait autorité.filenamereste interdit, en métadonnée comme ailleurs. C'est de la donnée personnelle sans consommateur, et elle finirait dans un journal (§12).- Le dernier segment de
Locationest l'upload_idqu'un job référence. Il n'y a pas deux identifiants à faire correspondre.
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
Découpez. Le protocole accepte un
PATCHunique, mais c'est précisément ce que le proxy refuse. 4 Mio par tronçon est la valeur recommandée — celle que Notula emploie : assez gros pour limiter les aller-retours, assez petit pour qu'une coupure coûte peu.Et il y a une seconde raison, qui mordrait même si le corps passait : un tronçon de 4 Mio se transfère en quelques secondes, donc le délai d'inactivité de cent secondes d'un proxy ne devient jamais un sujet. Un
PUTunique de 150 Mo le franchit sur une liaison montante ordinaire.Un
Upload-Offsetqui ne correspond pas à ce que la plateforme a reçu donne409UPLOAD_OFFSET_CONFLICT. C'est ce qui rend une reprise sûre plutôt qu'optimiste.Chaque requête porte la clé d'API. L'URL n'est plus signée : ce n'est plus le lien qui autorise, c'est l'appelant, à chaque tronçon.
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
creation,expirationettermination, et pas davantage. Pas deconcatenation: plusieurs médias se déclarent dansinput.media(§5.2), où l'ordre est celui du client et la jonction celle de la plateforme.DELETE /v1/uploads/{id}abandonne un envoi et libère sa place — c'esttermination.
Les deux durées, inchangées dans leur esprit
Upload-Expiresborne la vie de l'envoi en cours : 24 heures depuis la création. Passé ce délai, les octets reçus sont supprimés et l'identifiant ne référence plus rien. C'est ce qui remplace les 15 minutes pour commencer : avec la reprise, « commencer » n'a plus de sens — un envoi peut légitimement s'étaler sur une journée.Un envoi terminé reste référençable 24 heures, puis est supprimé s'il n'a servi à aucun job.
UPLOAD_EXPIREDcouvre les deux cas.Types acceptés :
audio/mpeg,audio/wav,audio/mp4,audio/x-m4a,video/mp4. Uncontent_typedéclaré hors de cette liste →415UNSUPPORTED_MEDIA_TYPE, à la création.Et les octets sont jugés au dernier tronçon. Le
PATCHqui achève l'envoi lit les premiers octets du fichier assemblé ; s'ils ne correspondent à aucune signature de la liste ci-dessus, il rend415UNSUPPORTED_MEDIA_TYPEet l'envoi n'existe plus — les octets sont supprimés, l'identifiant ne référence rien.Le refus arrivait auparavant à l'exécution du job. Mêmes octets envoyés, même verdict, quatre minutes plus tard — et entre les deux la plateforme avait loué une carte graphique pour un travail qui ne pouvait pas aboutir. Mesuré le 27/09/2026 : douze minutes de machine pour un appel refusé en quelques millisecondes dès qu'on l'a regardé.
Ce contrôle est une comparaison d'octets, donc il ne peut ni échouer ni hésiter. Il ne faut pas le confondre avec le sondage du §5.2, qui interroge un outil externe et dont le silence veut dire « on n'a pas pu savoir » — celui-là ne refuse jamais rien.
video/mp4est accepté, et la piste vidéo est retirée avant toute transcription. Rien d'elle ne quitte le serveur : elle n'est ni envoyée au moteur, ni conservée. Vous n'avez donc pas à extraire l'audio vous-même, mais vous pouvez toujours y gagner — c'est la même durée d'enregistrement pour une fraction des octets à envoyer, et le plafond de 500 000 000 octets se mesure sur ce que vous envoyez, pas sur ce qui reste après.Ajouté le 17/09/2026. Ce que ça corrige n'est pas seulement une commodité : le contrôle sur les octets acceptait déjà ce conteneur — un
.mp4porte presque toujours la marqueisomoump42, que la plateforme lit commeaudio/mp4— pendant que le type déclaré le refusait. Le contrat dit ailleurs que le type déclaré n'est jamais cru ; il ne pouvait pas être en même temps le seul à faire échouer un envoi que les octets auraient accepté.Un job ne peut référencer qu'un envoi terminé. Un
upload_iddont le décalage n'a pas atteint sa longueur donne409UPLOAD_INCOMPLETE: la moitié d'un enregistrement se transcrirait sans erreur et sans rien dire.Chaque tronçon est chiffré à sa réception, et rangé comme un objet distinct : un magasin d'objets ne sait pas ajouter en fin de fichier. La conséquence est celle qu'on veut — un transfert interrompu ne laisse jamais de clair sur le disque, pas même quelques mégaoctets d'un enregistrement abandonné.
Ce n'est pas le chiffrement adressable par intervalle de Notula, qui sert à relire un média en
Range: Broulala lit une fois puis supprime, et n'a besoin d'aucune propriété de recherche (CLAUDE.md §3). Les deux sont indépendants.
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" }
]
}
- Les identifiants sont ceux du client. Broulala ne les réécrit pas, ne les renumérote pas et ne les interprète pas : elle les rend tels quels dans les citations. Un client peut donc envoyer ses propres clés primaires et recoller le résultat sans table de correspondance.
- Ils doivent être uniques dans le job (
422DUPLICATE_SEGMENT_ID) et faire au plus 64 caractères. start_ms/end_mssont facultatifs. Absents, les citations restent possibles ; seul le repère temporel manque.speakerest présent si et seulement sidiarizeétait demandé, et vautnullsinon. C'est un identifiant opaque et stable à l'intérieur d'un job —speaker_1,speaker_2—, jamais un nom : la plateforme regroupe des voix, elle ne reconnaît personne. Nommerspeaker_1« Martin Vasseur » est le travail du client, chez lui, avec ce qu'il sait de sa réunion. Même règle que pour les identifiants de segment : la plateforme rend ce qu'elle a frappé, et n'apprend rien de plus.- Un segment porte un locuteur et un seul. Une prise de parole qui change de voix est deux segments, jamais un segment à deux étiquettes.
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 }
}
| Champ | Obligatoire | Rôle |
|---|---|---|
input | oui | §5.2 |
language | non | La 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.diarize | non | Sé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_speakers | non | Bornes, 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.vocabulary | non | Liste 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_id | non | Absent, le job transcrit et s'arrête là. |
template_version | non | Absent, 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. |
context | selon template | Validé contre context_schema. Donnée, jamais instruction (§2). |
service | non | §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
}
stagevautwaiting_capacity,transcribingougenerating. Il ne remplace passtatus: c'est un renseignement d'affichage, et un client n'a pas le droit d'en faire une machine à états.waiting_capacityest le seul qui porte une décision — c'est l'attente que le client a demandée (§7).Un job peut être
queuedavecstage: "generating", et ça veut dire que sa transcription est faite. Depuis le 18/09/2026, un job média qui porte untemplate_idest transcrit dès qu'un moteur de transcription est disponible, sans attendre la machine de génération ; sa transcription est alors conservée, et il retourne en file jusqu'à ce que la génération soit servie. Rien n'est re-transcrit : l'étape franchie l'est pour de bon. Pour un client, c'est un job qui met plus longtemps à finir et qui n'a rien perdu — etservice.waited_secondscompte cette attente comme les autres.estimated_completion_atpeut valoirnull, et vautnullpar défaut : elle n'est renseignée que lorsque la plateforme dispose d'une mesure, jamais d'une extrapolation de confort. Aucun pourcentage n'est exposé : les moteurs n'en rendent pas, et en fabriquer un serait mentir proprement. Vérifié dans le code du moteur le 20/09/2026 plutôt qu'affirmé —docs/etude-progression.md. La barre de progression se dessine côté client :started_at, l'instant courant et cette date suffisent à en calculer une, et la fraction reste alors une décision d'affichage au lieu d'être une affirmation de la plateforme sur un travail qu'elle ne voit pas.
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": []
}
}
result.transcriptest présent dès que l'entrée était un média, template ou non. Il estnullquand l'entrée était déjà du texte.result.outputest présent si et seulement si untemplate_ida été fourni. Enkind: "markdown", il portemarkdownau lieu dedata.result.engineest la provenance, et elle dit ce qu'elle a constaté — pas ce qui était configuré.transcription_modeletgeneration_modelnomment les moteurs qui ont réellement répondu ; ils valentnullpour une étape qui n'a pas eu lieu. Un client qui doit rester capable d'interpréter une vieille sortie persiste ce bloc avec elle,template_versioncompris.Une valeur qui porte un
@a été constatée ; une valeur sans@a été configurée. C'est la forme même du champ qui vous le dit, et il n'y a rien d'autre à lire pour le savoir :valeur ce que ça veut dire qwen2.5-14b-instruct-q4_k_m-00001-of-00003.gguf@b466e1f8c071…le moteur a nommé le fichier de poids qu'il a chargé, et la révision d'où il vient qwen2.5-14b-instructle moteur ne sait pas le dire, ou ne l'a pas dit ce jour-là : c'est le nom que la plateforme lui a donné Deux sorties dont la partie après
@diffère n'ont pas été écrites par les mêmes poids, même si le nom devant est identique — c'est exactement ce que ce champ existe pour rendre visible, et c'est tout ce qu'il promet. Il ne promet pas que les poids soient épinglés : la plateforme constate, elle ne fige pas (décision B16).transcription_modeln'a pas de@aujourd'hui, et c'est une limite du moteur et non un oubli :whisper-asr-webservicene rend nulle part le modèle qu'il a chargé. Le nom est donc celui de la configuration, et son absence de@le dit. Le jour où le moteur sait répondre, la valeur gagne un@et rien d'autre ne bouge.warnings: signalements non bloquants, toujours présents (tableau vide s'il n'y en a pas), jamais absents. Chaque entrée est{ "code": "…", "message": "…" }— lecodeest stable et documenté au §11.2, lemessagene l'est pas.
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
- Intervalle minimum entre deux
GET /v1/jobs/{id}: 10 secondes. - Reculement recommandé : 10 s → 20 s → 30 s → 60 s (plateau).
- Un job qui reste en
processingplus de 2 heures passe enerroravec le codePROCESSING_TIMEOUT. Le temps passé enwaiting_capacityn'entre pas dans ce décompte : il est borné séparément parmax_wait_seconds(§7). - Un client ne doit jamais poller sans borne. Prévoir un abandon — trois heures, par exemple — avec alerte humaine.
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"
}
}
Jamais de contenu. Ni transcription, ni sortie générée, ni extrait, ni nom de fichier. Le client apprend qu'il doit aller voir, et va voir avec sa clé par
GET /v1/jobs/{job_id}.Cinq raisons, dont une seule suffirait : une transcription de réunion pèse des centaines de kilo-octets et les reprises la réexpédieraient autant de fois ; un gros corps atterrit dans les journaux d'accès et les tampons de proxy du destinataire, ce que le §12 interdit ; une signature se vérifie sur les octets exacts, et un corps volumineux pousse à analyser avant de vérifier, ce qui est la faille classique ; et surtout une livraison peut se perdre — si le résultat ne vient que par là, une livraison perdue perd la donnée, alors qu'elle ne coûte qu'un
GETautrement.Bénéfice de bord : un message qui ne révèle rien n'a pas besoin d'être confidentiel, et un webhook forgé ne provoque qu'un
GETinutile.template_idest rappelé pour qu'un client puisse router sans lire sa base. Ce n'est pas du contenu.Un événement par changement de
status, donc trois au plus par job. Les changements destage(§6.2) n'en produisent aucun : c'est du détail d'affichage, et appelerwaiting_capacity → transcribingremplirait un journal sans que personne n'agisse dessus.
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}".
- Vérifier avant d'analyser le corps, en comparaison à temps constant.
- Rejeter un
tqui s'écarte de plus de cinq minutes de l'heure du destinataire : sans cette borne, un message capturé se rejoue indéfiniment. - Le secret est rendu une seule fois, à sa création côté administration, exactement comme une clé API.
Reprises, ordre, idempotence
- Reprises sur toute réponse hors
2xxou tout échec réseau : 1 min, 5, 15, 60, 360, puis abandon. Le job, lui, ne bouge pas : il reste consultable. event_idest le registre d'idempotence. Une redélivrance porte le même identifiant ; le destinataire l'écrit dans la transaction qui agit dessus, de sorte qu'une seconde livraison entre en collision et ne fasse rien.- L'ordre n'est pas garanti, et il n'a pas à l'être : le message ne porte pas d'état
qu'on applique, il dit d'aller lire. Un
processingarrivé après undonene peut rien défaire, puisque la vérité est leGET. - Le destinataire répond vite,
2xxet corps vide, et fait son travail après. Une réponse au-delà de dix secondes compte comme un échec. - Les redirections ne sont pas suivies. Un
3xxest un échec.
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"
}
| Champ | Sens |
|---|---|
mode | full, degraded ou unavailable |
serves | Ce que la plateforme accepte maintenant : transcription, generation, les deux, ou aucun. Additif, ajouté le 18/09/2026 — voir ci-dessous. |
mode_until | Un 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_since | Depuis quand, ou null. C'est ce qui distingue un creux d'une panne. |
queue_depth | Jobs en attente, toutes clés confondues |
observed_at | Quand 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.
serves | Ce 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
modesuffisait. Il suffisait tant que la génération tournait sur la machine de la transcription :degradedse 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 unCAPACITY_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 :
mode | serves | ce 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
servesse comporte exactement comme avant, et cette fois sans réserve :moden'a pas changé de sens, donc rien de ce qu'il en déduisait n'a bougé.Une première rédaction posait que «
fullimplique les deux services ». Elle aurait fait demodeun axe de disponibilité générale, etmoderoute : 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'annoncerdegraded, 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 :
Bail périmé —
modevautdegraded, quel que soit le mode enregistré, etmode_untilest rendu tel quel, donc dans le passé. Le client voit à la fois la réponse sûre et la raison pour laquelle elle est sûre.degradedest l'échec sûr dans les deux sens : annoncerfullserait un mensonge, etunavailablearrêterait un client alors que personne n'a constaté de panne. Avecon_degraded: "wait", un job est retenu plutôt que lancé sur un chemin qui n'est peut-être pas là — ce qui est le bon comportement quand on ignore l'état.servesne suit pas la même règle, parce qu'il n'a pas la même question.modepeut se rabattre sur une valeur sûre ;servesdit qu'un moteur répond, et cela ne se devine pas.generationdisparaît donc de la liste sans rien demander — elle n'a pas de jumeau dégradé, donc l'absence est la seule chose affirmable sans regarder. Pour la transcription, la plateforme interroge son propre moteur au moment de répondre. Un bail périmé n'est pas un état neutre : c'est un incident, un redémarrage, un déploiement — précisément les moments où ce moteur-là a le plus de chances d'être arrêté lui aussi. Vous pouvez donc liremode: "degraded"avecserves: [], et ce n'est pas une contradiction : le mode est ce qu'on suppose,servesest ce qu'on a constaté.Aucune ligne, parce qu'aucun superviseur n'a jamais tourné —
modevautdegraded,mode_untilcommeobserved_atvalentnull, etservesest constaté de la même façon que ci-dessus. La plateforme ne fabrique pas d'horodatage pour une observation qui n'a pas eu lieu (§2.7 du dépôt) :nulldit « jamais observé », là où une date inventée dirait « observé », ce qui est faux.
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 }
proceed(par défaut) : le job part sur ce dont il a besoin, dès que c'est disponible. Une transcription seule part tout de suite sur le chemin dégradé, puisqu'il rend le même résultat plus lentement.wait: le job reste enqueued,stage: "waiting_capacity", jusqu'à ce que le mode complet revienne. C'est le bon choix quand le chemin dégradé rend un résultat différent et moindre — auquel cas le lancer quand même dépense une tentative sur une certitude d'échec.Il porte sur la transcription, et sur elle seule.
on_degradedest le nom de ce qu'il refuse : le chemin dégradé, qui est un moteur de transcription. Un job dont l'entrée est déjàtextoutranscriptn'en emprunte aucun, doncwaitne le retient pas — le faire attendre unfulldont il n'a que faire l'aurait laissé expirer enCAPACITY_UNAVAILABLEpendant que la machine dont il avait besoin, elle, répondait.Un job qui porte un
template_idest retenu en mode dégradé, même avecproceed. Ce n'est pas la plateforme qui passe outre le choix du client :proceedveut dire « pars avec ce qui est disponible », et pour ce job-là, ce dont il a besoin ne l'est pas. Le retenir est ce queproceeddemande, lu honnêtement — le lancer produirait une transcription puis un échec certain à l'étape suivante, en ayant dépensé le temps machine et le quota.Il attend donc comme un
wait, sans consommer de tentative, etmax_wait_secondsle borne de la même façon. Un client qui veut savoir avant d'attendre litGET /v1/capacity: c'estservesqui le dit, et l'absence degenerationdans la liste est exactement ce fait.degradedne le dit plus depuis B15 — il ne parle que du chemin de transcription.Aucun job n'est pris quand ce dont il a besoin n'est pas servi,
proceedcompris. C'est la même lecture, poussée à son terme :proceeddit « pars avec ce qui est disponible », et quand ce dont ce job-là a besoin ne l'est pas, il n'y a rien avec quoi partir. Écrit « en modeunavailable» jusqu'au 18/09/2026, ce qui était vrai d'une plateforme à un seul axe : depuis B15,mode: "unavailable"avecserves: ["generation"]prend un job dont l'entrée est déjà un transcript, parce que tout ce dont il a besoin est là. Le prendre quand même, c'est le faire échouer contre un moteur qui ne répond pas, puis recommencer — l'échelle de reprise se vide pendant qu'une machine démarre, et le job finit par réussir bien plus tard qu'il ne l'aurait fait en attendant. Mesuré le 17/09/2026 : quatre tentatives perdues et cinq minutes de recul avant la cinquième, qui a réussi du premier coup.Ce que ça change pour vous : un job
proceedqui échouait en une seconde attend désormais,stage: "waiting_capacity", borné parmax_wait_secondscomme les autres. Plus lent à échouer, et il réussit plus vite quand il réussit.Et
degradedveut dire qu'un moteur a répondu. La plateforme ne l'annonce pas parce qu'elle possède un moteur de secours, mais parce qu'elle vient de le joindre. S'il ne répond pas, le mode estunavailable— ce qui est la vérité, et ce qui fait attendre les jobs au lieu de les jeter contre lui.max_wait_secondsborne l'attente : 1800 par défaut, 21600 au plus. À l'expiration, le job passe enerroravecCAPACITY_UNAVAILABLE. Il ne bascule pas en dégradé : le client avait dit non.
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
queuedcouvre l'attente de capacité (stage: "waiting_capacity") comme l'attente ordinaire.- Un job
doneouerrorest terminal, jamais retraité. Pour retraiter, créer un nouveau job avec une nouvelleIdempotency-Key. stageest nul sur un état terminal.
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.
- Les invariants de la plateforme sont en tête du message système, et aucun template ne peut les retirer.
- Les instructions du template suivent.
contextarrive 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
| Enum | Valeurs |
|---|---|
status | queued, processing, done, error |
stage | waiting_capacity, transcribing, generating, ou null |
input.type | media, text, transcript |
output.kind | markdown, json |
service.on_degraded | wait, proceed |
capacity.mode / service.mode | full, degraded, unavailable (unavailable seulement sur la capacité) |
type (webhook) | job.status_changed |
language | fr |
content_type | audio/mpeg, audio/wav, audio/mp4, audio/x-m4a, video/mp4 |
template_id | le 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.
| Code | HTTP | Signification |
|---|---|---|
INVALID_API_KEY | 401 | Clé absente, inconnue, désactivée ou révoquée — les quatre répondent pareil |
QUOTA_EXCEEDED | 403 | Volume mensuel épuisé — details.resets_at, details.limit_minutes |
BUDGET_EXCEEDED | 403 | Plafond de sécurité en argent atteint — details.resets_at |
ORGANISATION_SUSPENDED | 403 | L'organisation est suspendue : création d'envoi et de job refusées, lecture et suppression toujours ouvertes |
UPLOAD_NOT_FOUND | 404 | upload_id inconnu, ou fichier jamais reçu |
UPLOAD_EXPIRED | 410 | Envoi non terminé au-delà d'Upload-Expires, ou job créé plus de 24 h après la complétion |
UNSUPPORTED_MEDIA_TYPE | 415 | content_type fourni hors liste, ou octets dont le type réel n'est pas supporté |
PAYLOAD_TOO_LARGE | 413 | Fichier au-delà du plafond par fichier |
UPLOAD_OFFSET_CONFLICT | 409 | Le Upload-Offset d'un PATCH ne correspond pas à ce qui a été reçu — reprenez depuis le HEAD |
UPLOAD_INCOMPLETE | 409 | Un job référence un envoi dont le décalage n'a pas atteint sa longueur |
INPUT_TOO_LARGE | 413 | Entrée du job au-delà du plafond de job (§14) |
MEDIA_INCOMPATIBLE | 422 | Un média ne peut être joint aux autres — details.index nomme lequel, details.reason dit quoi (codec, sample_rate, channels) |
DUPLICATE_SEGMENT_ID | 422 | Deux segments d'entrée portent le même id — details.id |
INPUT_NOT_ANCHORABLE | 422 | Le template exige des citations, l'entrée ne porte pas de segments |
INVALID_TEMPLATE_ID | 422 | Aucun template de ce nom au catalogue, ou aucune version publiée |
LANGUAGE_NOT_SUPPORTED | 422 | Le language du job n'est pas dans les languages du template — details.supported les liste |
TEMPLATE_VERSION_NOT_FOUND | 422 | Version demandée inexistante |
CONTEXT_INVALID | 422 | context ne respecte pas le context_schema — details.errors porte les chemins fautifs |
IDEMPOTENCY_KEY_REUSED | 409 | Même clé, corps différent, dans les 24 h |
JOB_NOT_FOUND | 404 | job_id inconnu ou hors de cette clé |
RATE_LIMITED | 429 | Quota dépassé — voir Retry-After |
INTERNAL_ERROR | 500 | Erreur imprévue — le client peut retenter avec la même Idempotency-Key |
PROCESSING_FAILED | 200, dans error | Échec métier : audio illisible, silence total, aucune parole |
PROCESSING_TIMEOUT | 200, dans error | Dépassement des 2 h de traitement |
CAPACITY_UNAVAILABLE | 200, dans error | max_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_INVALID | 200, dans error | Le 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.reason | Ce qui s'est passé | Ce que vous pouvez en faire |
|---|---|---|
schema | La sortie ne respecte pas le schéma du template | Le template est en cause, pas votre appel. Signalez-le |
truncated | Le moteur a écrit jusqu'à son plafond de jetons et s'est arrêté au milieu | Le template produit trop pour ce qu'il demande. Signalez-le |
citations | Aucune citation ne tenait — voir §4.1 | Recommencer 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
| Code | Sens |
|---|---|
LOW_AUDIO_QUALITY | Signal dégradé sur tout ou partie de l'enregistrement |
PARTIAL_SILENCE | Longues plages sans parole |
UNCERTAIN_DIARIZATION | Le regroupement des voix est peu sûr sur tout ou partie de l'enregistrement — details.from_ms / details.to_ms |
VOCABULARY_IGNORED | Des entrées de vocabulary ont été écartées (trop nombreuses, ou trop longues) — details.count |
CONTEXT_FIELD_UNUSED | Un champ de context n'est employé par aucune version de ce template — signale une fiche technique périmée côté client |
CITATIONS_DISCARDED | Des é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
- Aucun contenu d'entrée ni de sortie n'apparaît dans les journaux applicatifs, ni côté Broulala ni côté client. Sont journalisables :
job_id,upload_id,request_id, horodatages, codes d'erreur, tailles, durées. - Les médias et les textes sont chiffrés au repos dès la réception, y compris pendant les 48 h de conservation après échec (§14).
- Un
messaged'erreur ne cite jamais le contenu traité. - L'envoi s'authentifie à chaque tronçon par la clé d'API, jamais par une URL qui autoriserait d'elle-même.
13. Limitation de débit
- Headers sur chaque réponse :
X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset(timestamp Unix). - Quota fixé par clé API, ajusté au volume réel de chaque intégration.
429avecRetry-After(secondes) — le client doit respecter cette valeur.- Deux quotas distincts : un pour les appels d'API, un pour les jobs créés par fenêtre. Un client dont les utilisateurs déposent tous le dimanche soir se heurte au second sans que le premier bouge, et ne doit pas avoir à deviner lequel des deux l'a arrêté — le
details.quotadeRATE_LIMITEDle dit (requestsoujobs). GET /v1/capacityn'est décompté d'aucun des deux.
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.
| Plafond | Dépassement | |
|---|---|---|
| Volume | quota_minutes par organisation et par mois | 403 QUOTA_EXCEEDED |
| Argent | budget_cents par organisation et par mois | 403 BUDGET_EXCEEDED |
403et non429. UnRetry-Afterde dix-huit jours n'a pas de sens : ce n'est pas un problème de rythme, c'est un état de l'organisation — le même genre de refus queORGANISATION_SUSPENDED.details.resets_atdit quand la période se renouvelle.- Deux codes plutôt qu'un, parce que la conversation à tenir n'est pas la même : un quota épuisé se recharge, un disjoncteur qui saute est le plus souvent un défaut du client et mérite un appel.
- Le contrôle porte sur ce qui est déjà consommé, jamais sur une estimation de ce que ce job coûtera. On ne connaît la durée d'un média qu'après l'avoir sondé, et refuser sur une estimation ferait rejeter des jobs parfaitement admissibles. Conséquence assumée : un job peut faire dépasser le plafond de sa propre durée, une fois. C'est borné et c'est simple ; l'inverse serait approximatif et compliqué.
- Comme pour la suspension : la consommation s'arrête, la récupération continue. Un job en cours va à son terme, et lire comme supprimer restent ouverts.
nullveut dire illimité, jamais un grand nombre : un999999finit affiché tel quel sur une jauge. Les deux champs sont indépendants — une organisation en volume illimité garde son disjoncteur, parce qu'il ne protège pas d'un client mais d'une boucle.- Illimité mesure quand même. Seul le plafond disparaît, pas le compteur : l'organisation sans limite est précisément celle dont on veut connaître la consommation.
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.
- C'est un courriel, et pas un événement de webhook. Le §6.5 pose « un événement par
changement de
status» et le §15 refuse l'abonnement par type : un message de quota serait un second type sur ce canal, donc une décision sur l'enveloppe, et personne ne l'a demandée. Le jour où un client la demande, elle s'arbitre — le courriel, lui, n'ouvre rien. - Il ne porte rien de ce que vous avez envoyé, ni même le nom de votre organisation : des minutes, des euros, une date. Même règle que le courriel d'invitation (décision B22).
- Le seuil ne change rien au plafond. Il n'avance ni ne recule le refus : à 100 %, la création s'arrête comme avant, et la lecture comme la suppression restent ouvertes.
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_centsvaut toujoursnull— jamais un zéro, qui voudrait dire « gratuit » là où la vérité est « on ne sait pas » (§2.7 deCLAUDE.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 maximale | 4 heures par job, tous médias joints |
| Taille par fichier | 500 Mo |
| Taille par job | 1 Go, tous médias confondus |
| Locuteurs distincts par job | 20 |
Segments par entrée transcript | 20 000 |
Caractères par entrée text ou transcript | 600 000 |
Entrées de vocabulary | 200, de 64 caractères au plus |
Rétention
| Donnée | Durée |
|---|---|
| Envoi reçu, non encore référencé par un job | 24 h après sa création, puis supprimé |
| Média brut | supprimé 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ée | supprimé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 vocabulary | immédiatement, sur DELETE /v1/jobs/{id}. La ligne de l'appel demeure : voir §6.3 |
Termes d'un vocabulary | avec le reste du contenu. Seul leur nombre subsiste, parce qu'il explique une durée sans nommer personne |
| Compteurs d'usage journaliers | 13 mois |
| Trace d'une livraison de webhook | 30 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 |
| Tarifs | conservé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 à
NULLen 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.
- Une URL de webhook par job. Elle est déclarée sur l'intégration (§6.5) et rien
ne la surcharge. Un client qui doit router plusieurs destinations le fait chez lui, à
partir du
template_idque le message rappelle : accepter une adresse dans un corps de job rouvrirait toute la surface SSRF que cette décision ferme. - Un webhook qui porte le résultat. Voir le §6.5 : ce serait échanger un
GETcontre une perte de donnée silencieuse le jour d'une livraison manquée. - Un abonnement par type d'événement. Trois messages au plus par job ; un client qui ne s'intéresse qu'aux états terminaux les ignore, ce qui coûte moins cher qu'un modèle d'abonnement à maintenir des deux côtés.
context_idsur un job. Un client construit soncontextdepuis son propre magasin (§4.2). Résoudre un contexte enregistré côté serveur est une commodité pour un client qui n'en a pas ; aucun ne l'a demandé, et l'ajouter d'avance serait une seconde façon de dire la même chose. La ressource qui aurait pu le porter a d'ailleurs été construite puis retirée le 18/09/2026, faute de lecteur.- Création ou modification de template en self-service. Le catalogue se lit par l'API, il ne s'écrit pas : c'est une bibliothèque commune, tenue côté Broulala par le super administrateur.
- Citations dans une sortie
markdown. Les citations existent pour les sortiesjsonviasegment_ref. Une syntaxe de citation dans du Markdown serait un second format à analyser, et personne ne l'a demandé. - Pourcentage de progression. Les moteurs n'en rendent pas.
estimated_completion_atest tout ce qui peut être dit honnêtement. - Génération de rendu HTML stylé, DOCX ou PDF.
- Authentification OAuth. Une clé API statique par intégration.
- Multi-tenant à l'intérieur d'une même clé. Une clé identifie une intégration, pas des sous-comptes.
- Relance automatique d'un job en erreur. Le client recrée explicitement.
- Une langue de sortie distincte de la langue d'entrée. Résumer en anglais une réunion tenue en français est une demande légitime, et personne ne l'a faite. Le jour venu, c'est un champ de plus sur le job — pas une réinterprétation de celui qui existe.
- La détection automatique de langue (
language: "auto"). Voir §6.1 : une détection fausse est silencieuse, et c'est la forme d'échec que ce contrat refuse partout. - Support multilingue.
Reprise d'envoi.Elle existe depuis le 17/09/2026 — voir le §5.1. L'envoi se fait en tus : un transfert interrompu se reprend au décalage que le serveur annonce, et rien ne se refait entièrement. Cette ligne décrivait lePUTunique qui précédait, et unPUTunique de 150 Mo est refusé par le CDN devant la plateforme — mesuré, c'est ce qui a imposé le changement (décision B10). Ce que tus ne donne toujours pas : la continuation en arrière-plan. Une page fermée arrête le transfert ; elle ne le perd pas.
16. Ce qui change depuis la 1.1, et pourquoi
| # | Changement | Raison |
|---|---|---|
| 1 | La transcription est exposée dans result | Elle é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. |
| 2 | template_id devient facultatif | Permet de ne transcrire que. Sans ça, obtenir un transcript obligeait à demander un document dont on n'a que faire. |
| 3 | Entrées text et transcript | Ré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. |
| 4 | Le client fournit ses propres identifiants de segment, et ils sont rendus tels quels | Un client recolle le résultat sur ses objets sans table de correspondance, et la plateforme n'a rien à mémoriser. |
| 5 | output.kind : markdown ou json avec schéma | C'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. |
| 6 | Type 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. |
| 7 | GET /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. |
| 8 | engine dans le résultat : modèles réellement employés, template_version | Le 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. |
| 9 | GET /v1/capacity et service.on_degraded | Le 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. |
| 10 | service.mode dans le résultat | Sans lui, un client qui estime des durées mélange deux populations et sa barre de progression ment dans les deux sens. |
| 11 | DELETE /v1/jobs/{id} | Un client soumis à une obligation d'effacement doit pouvoir supprimer ici le jour où il supprime chez lui. |
| 12 | Plusieurs médias joints par job, avec refus nommant l'indice | Un 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. |
| 13 | transcription.vocabulary | Les 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. |
| 14 | Idempotency-Key avec corps différent → 409 | Rendre silencieusement l'ancien résultat masquerait une erreur du client. |
| 15 | stage, et estimated_completion_at nullable | De quoi afficher quelque chose d'honnête, sans jamais inventer un pourcentage. |
| 28 | Quota 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. |
| 27 | Compteurs d'usage journaliers, 13 mois, seule chose qui survit à la purge | Un 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. |
| 26 | Une 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. |
| 25 | ORGANISATION_SUSPENDED (403), et la suspension n'arrête que la consommation | Un 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. |
| 24 | language (job) et languages (template) sont deux champs distincts | Ils 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. |
| 16 | language avec une seule valeur | Une seconde langue ne sera pas un changement de forme. |
| 17 bis | Le 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. |
| 17 | Le type réel d'un média est vérifié sur les octets | Un content_type cru sur parole donne une transcription vide plutôt qu'une erreur, ce qui est le pire des deux. |
| 18 | Les invariants de composition du prompt (§9) deviennent une garantie écrite | Un client a besoin de savoir que ce qu'il met dans context ne peut pas déshabiller un template. |
| 19 | Le 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
-
GET /v1/templatesrend le catalogue de la plateforme — le même pour deux clés de deux organisations — avecoutput.schemaetcontext_schema -
POST /v1/uploadsrend unLocation, refuse uneUpload-Lengthhors plafond, etOPTIONSannoncecreation,expiration,termination - Aucun endpoint n'accepte ni ne renvoie de nom de fichier
- Un fichier dont les octets contredisent le
content_typedéclaré fait échouer le job - Quota épuisé :
POST /v1/jobsdonne403 QUOTA_EXCEEDEDavecresets_at,GETetDELETErépondent normalement -
quota_minutes: nulln'empêche aucun job et continue d'incrémenter les compteurs -
quota_minutes: nullavecbudget_centsfixé : le disjoncteur s'applique quand même - Changer un tarif ne modifie aucune ligne d'usage antérieure (test sur une journée close)
- Une organisation suspendue :
POST /v1/uploadsetPOST /v1/jobsdonnent403 ORGANISATION_SUSPENDED,GETetDELETErépondent normalement - Un job en cours au moment d'une suspension va à son terme et reste récupérable
- Clé inconnue, désactivée et révoquée donnent une réponse indiscernable
- Une clé désactivée puis réactivée fonctionne de nouveau ; une clé révoquée, jamais
- Un
languageabsent deslanguagesdu template donneLANGUAGE_NOT_SUPPORTEDavec la liste des langues acceptées -
language: "auto"est refusé comme toute valeur hors enum - Sans
diarize, aucun segment ne porte despeaker, et aucune provenance de diarisation n'entre dans l'enveloppe - Avec
diarize, chaque segment porte exactement unspeaker, et jamais un nom de personne -
max_speakersest respecté : un job borné à 2 ne rend jamais trois locuteurs -
POST /v1/jobssanstemplate_idproduit un job dont le résultat porte la transcription et pas de sortie -
POST /v1/jobsavec une entréetranscriptne transcrit rien et rendtranscript: null - Les
segment_iddu client sont rendus à l'identique dans les citations - Une sortie citant un
segment_idabsent de l'entrée ne peut pas atteindrestatus: done - Une sortie dont un
textde citation ne figure pas dans le segment cité ne peut pas atteindrestatus: done - Les indices d'une citation sont calculés par la plateforme, et une sortie de moteur n'en porte jamais
- Une sortie
jsonnon conforme au schéma du template donneOUTPUT_INVALID, jamais undone - Une sortie tronquée par épuisement du budget de jetons est traitée comme un échec
- Un élément dont la citation ne tient pas est écarté, les autres sont rendus, et
CITATIONS_DISCARDEDdit combien et pourquoi - Une sortie dont aucune citation ne tient donne
OUTPUT_INVALID, jamais undoneavec un tableau vide -
OUTPUT_INVALIDportedetails.reasonparmischema,truncatedetcitations - Le
textd'une citation est borné par le plus long segment de l'entrée, dans la grammaire envoyée au moteur - Une transcription vide sur un audio non nul donne
PROCESSING_FAILED, jamais undone -
contextnon conforme aucontext_schemadonneCONTEXT_INVALIDavec les chemins fautifs - Un
contextcontenant une consigne ne modifie pas le comportement du template (test dédié) -
Idempotency-Keyidentique + corps identique → mêmejob_id; corps différent →409 - Un envoi interrompu reprend :
HEADrend le décalage reçu, lePATCHsuivant repart de là, et un décalage faux donne409 UPLOAD_OFFSET_CONFLICT - Un envoi reçu est encore référençable 23 h plus tard, et ne l'est plus à 25 h
- Après
MEDIA_INCOMPATIBLE, un nouveau job sur les mêmesupload_idmoins la partie fautive aboutit sans rien renvoyer - Deux médias de codecs différents donnent
MEDIA_INCOMPATIBLEavec l'indice du fautif - L'ordre des médias joints est celui du tableau, jamais redéduit
-
on_degraded: "wait"maintient le job enqueued/waiting_capacityet n'entame pas le timeout de 2 h - Un job média à template est transcrit alors que seule la transcription est servie, et repart en
queued/generating - Ce job-là reprend à la génération sans re-transcrire, et son
engine.transcription_modelest celui du premier passage - En mode dégradé, un job sans
template_idpart et aboutit ; un job avec est retenu, même sousproceed -
GET /v1/capacityrendserves, et il vaut["transcription"]sur une plateforme qui transcrit sans savoir générer -
mode: "full"avecserves: ["transcription"]prend une transcription et retient un job à template -
serves: ["generation"]sans transcription prend un job dont l'entrée est déjàtranscript, et retient un job média -
on_degraded: "wait"ne retient pas un job qui n'emprunte aucun chemin de transcription - Un bail périmé cesse d'annoncer
generation, quelle que soit la dernière ligne écrite - Un job retenu faute de génération rend
CAPACITY_UNAVAILABLEà l'expiration, jamais une sortie partielle -
max_wait_secondsépuisé donneCAPACITY_UNAVAILABLEet ne bascule pas en dégradé -
service.modedu résultat reflète le mode réellement employé -
capacity.mode_untildans le passé est documenté comme « inconnu » et testé comme tel -
DELETE /v1/jobs/{id}rend204, deux fois de suite, et l'objet n'est plus accessible - Média supprimé après
done, vérifié par test - La purge d'un résultat efface aussi le
context— vérifié en relisant la ligne entière, pas les colonnes attendues -
DELETE /v1/jobs/{id}efface tout le contenu et conserve la ligne de l'appel ; unGETpostérieur répond200avecresult: null, jamais404 - Les termes d'un
vocabularyne survivent ni auDELETEni à la purge, et leur nombre survit aux deux - Un job en
errorperd son entrée, son contexte et sa transcription intermédiaire au-delà de 48 h -
engineporte les modèles réellement employés, etnullpour une étape non exécutée - La plateforme rend le nom du moteur tel quel,
@compris : elle ne le réécrit ni ne le recompose depuis sa configuration - Un webhook ne contient jamais de contenu (test lisant la charge utile entière)
- La signature est calculée sur
t+ corps brut, et untvieux de plus de cinq minutes est rejeté - Une redélivrance porte le même
event_id - Aucune URL de webhook ne peut être posée, lue ou surchargée par l'API d'intégration
- Un destinataire injoignable ne modifie pas l'état du job
- Aucun contenu d'entrée ni de sortie dans les journaux (test sur un corpus marqué)
- Toutes les erreurs du §11.1 sont couvertes par au moins un test
Côté système client
- Gère les quatre états,
errorcompris - Un envoi dont les octets ne sont d'aucun type accepté est refusé au dernier
PATCH, et son identifiant ne référence plus rien - Poll avec le reculement du §6.4, et abandonne avec alerte au-delà d'une borne
- Génère une
Idempotency-Keypar tentative logique, pas par requête HTTP - N'assume jamais la présence de
resultavantstatus: done - Persiste
engineettemplate_versionavec toute sortie qu'il conserve - Segmente ses statistiques de durée par
service.mode - Récupère et persiste le résultat avant les 30 jours
- Appelle
DELETE /v1/jobs/{id}quand il supprime la donnée correspondante chez lui - Continue de poller même en recevant des webhooks, à cadence réduite
- Vérifie la signature avant d'analyser le corps, en temps constant
- Traite
event_idcomme une clé d'idempotence, écrite dans la transaction qui agit - Répond
2xxen moins de dix secondes, et travaille après - Ne déduit aucun état du corps du webhook : la vérité est
GET /v1/jobs/{id} - N'affiche jamais
estimated_completion_atcomme une certitude, et gèrenull - Ne met dans
contextque ce que lecontext_schemadu template déclare