# Référence API : GoWebi

Base de production : `https://webinaire-api-fra.onrender.com`. Corps et réponses en JSON
(`content-type: application/json`). Une erreur répond `{ statusCode, message }` : `400` (donnée
invalide, le message dit laquelle et comment la corriger), `401` (clé ou session absente ou
refusée), `403` (pas les droits), `404` (introuvable), `409` (conflit), `429` (trop de demandes
d'un coup), `500`. Messages en français ; en anglais avec l'en-tête `accept-language: en`.

Cette page est aussi servie telle quelle, pour un agent : `https://gowebi.ch/docs/api.md`.
Un agent la télécharge (`curl -s https://gowebi.ch/docs/api.md -o api.md`) et la lit en
entier : un outil qui résume une page web en perdrait des détails.

## Piloter la plateforme avec une clé d'API (Claude Code, scripts)

Une clé d'API fait, au nom d'une organisation, tout ce que l'équipe fait dans l'éditeur : créer un
webinaire complet, écrire sa séquence d'emails et de SMS, régler ses pages, brancher ses outils, lire
les inscrits et les résultats. Toutes les routes marquées « Équipe » (ou sans « Public ») de cette
page lui sont ouvertes.

> **Webinaires automatisés débranchés pour l'instant.** La plateforme ne propose que les directs :
> `type: 'evergreen'` (création, brief, `PATCH /webinars/:id`), la copie d'un automatisé et
> `POST /webinars/:id/evergreen-copy` répondent 400. Les automatisés déjà créés continuent. Les
> sections de cette page qui les décrivent restent pour leur retour (`EVERGREEN=on` côté API).

1. **Créer la clé** : gowebi.ch → *Paramètres* → *Clés d'API* (propriétaire ou
   administrateur de l'organisation). Elle commence par `wsk_`, ne s'affiche qu'une fois, et se
   révoque au même endroit.
2. **L'envoyer à chaque appel** : en-tête `x-api-key: wsk_…`. Dans Claude Code, elle reste dans une
   variable d'environnement (`WEBINAIRE_API_KEY`), jamais dans le texte d'une demande.
3. **Trouver son organisation** : `GET /auth/me` → `orgs: [{ id, name, role, canBroadcast }]`.
   `canBroadcast: false` : compte pas encore activé ; le webinaire se prépare, mais ni ses emails ni
   son direct ne partent.
4. **Créer un webinaire complet en un appel** : `POST /webinars/from-spec`, le
   [brief complet](#brief-complet-post-webinarsfrom-spec) : réglages, formulaire, salle d'attente,
   offres, sondages, et la séquence de ses emails et SMS (avant, pendant et après le direct).
5. **Relire et ajuster** : `GET /webinars/:id` (tout le webinaire), `PATCH /webinars/:id/config`
   (un onglet de l'éditeur, voir ci-dessous), `GET /webinars/:id/emails`,
   `POST /webinars/:id/emails/preview` (rendu d'un email : envoyer les champs de l'email, recevoir
   `{ subject, html, from, replyTo }`), `GET /webinars/:id/sequence` (la séquence en arbre),
   `GET /public/webinars/:id` (sans clé : le formulaire tel qu'un visiteur le voit, libellés et
   bouton compris), `GET /webinars` (les webinaires de l'organisation, chacun avec `registrations`,
   ses ventes payées `sales` et leur chiffre net `sales_cents` dans la devise la plus vendue
   `sales_currency`), `GET /webinars/:id/scores` (inscrits et scores), `GET /webinars/:id/sales`
   (ses ventes), `GET /orgs/:orgId/contacts` (les contacts, tous webinaires confondus) et le
   parcours de chacun ([Contacts et parcours](#contacts-et-parcours-le-crm)).
6. **Préparer les slides du direct** (facultatif) : chaque page du PDF convertie en image
   (1920 × 1080, JPEG), envoyée par `POST /webinars/:id/slides/pages`, puis
   `PUT /webinars/:id/slides { name, pages }` avec les identifiants reçus, dans l'ordre. Ou une
   présentation HTML d'un seul fichier, qui expose son moteur dans `window.DECK` : envoyée telle
   quelle par `POST /webinars/:id/slides/deck`, puis `PUT /webinars/:id/slides { kind: 'html', name,
   deck }`. Le serveur y compte les slides et les étapes (convention ci-dessous) ; la régie ou l'éditeur
   les recomptent dans le moteur du deck à la première ouverture. L'hôte les retrouve dans la régie.

**Modifier un onglet** : `PATCH /webinars/:id/config` avec `{ <onglet>: { … } }`. Chaque onglet envoyé
**remplace** l'onglet enregistré en entier (`registration`, `waiting`, `replay`…) : lire
`GET /webinars/:id`, changer ce qu'il faut dans `config.<onglet>`, renvoyer l'onglet complet. Les
onglets absents du corps ne bougent pas.

```bash
export WEBINAIRE_API_KEY=wsk_...
curl -s https://webinaire-api-fra.onrender.com/auth/me -H "x-api-key: $WEBINAIRE_API_KEY"
curl -s -X POST https://webinaire-api-fra.onrender.com/webinars/from-spec \
  -H "x-api-key: $WEBINAIRE_API_KEY" -H 'content-type: application/json' -d @brief.json
```

**Ce qu'une clé ne fait pas** : créer d'autres clés, ouvrir une session, s'envoyer un email de test
(utiliser `POST /webinars/:id/emails/preview` pour voir le rendu), animer le direct (la régie reste
dans le navigateur de l'hôte).

**Bonnes pratiques d'un agent**
- Un `idempotencyKey` dans chaque brief : le renvoyer après une coupure rend le même webinaire, sans
  doublon (`created: false`).
- Un webinaire créé est publié : sa page d'inscription (`urls.register`) est en ligne dès la
  réponse. Donner à l'hôte ce lien et celui de l'éditeur (`urls.edit`) pour qu'il relise.
- Ne rien supprimer (`DELETE`) sans l'accord de l'hôte.
- Supprimer un inscrit se fait en deux appels : montrer à l'hôte ce que répond le premier (qui, et
  ce qui part avec lui), puis n'envoyer le second, avec son code, qu'après son accord.
- Une réponse `400` dit quoi corriger (« L’email n° 2 du brief : Ajoutez un objet. ») : corriger
  ce point et renvoyer le même brief. Un brief refusé ne crée rien.
- Dates : `scheduledAt` à l'heure locale du `timezone` (`2026-11-12T19:00`), ou un instant ISO
  avec décalage.

## Brief complet (POST /webinars/from-spec)

Tout est créé dans une seule transaction : si une partie est refusée, rien n'est créé.

| Champ | Contenu |
|---|---|
| `orgId` | Obligatoire : `GET /auth/me`, `orgs[0].id` |
| `idempotencyKey` | Conseillé. Un rejeu renvoie le webinaire déjà créé (`created: false`) |
| `webinar` | `{ title (obligatoire), subtitle?, type?: 'live' \| 'evergreen', scheduledAt?, timezone? (défaut Europe/Zurich), plannedDurationSec?, blockedWords? }`. `blockedWords` : les mots interdits du chat de ce webinaire, comme `PATCH /webinars/:id` (absent : aucun) |
| `config` | Les réglages de l'éditeur, mêmes clés que `PATCH /webinars/:id/config` (voir [Organisations & webinaires](#organisations--webinaires)) : `language` (`fr` par défaut, `en` : pages, libellés et bouton du formulaire, emails par défaut, étiquettes), `presenters: [{ name, role?, photoUrl? }]`, `registration` (le formulaire, voir ci-dessous), `waiting` (`subtitle`, `description`, `agenda: [texte]`, `countdownTitle`), `thanks`, `live`, `replay`, `emailVars` (variables propres : `{ lien_offre: 'https://…' }` → `{{lien_offre}}`), `sender`, `tracking`, `legal`, `access`, `evergreen` (automatisé : `videoUrl`, `schedule`…) |
| `theme` | `{ brandColor, radius, logoUrl, heroImageUrl, shareImageUrl, font? }`. `font` : police des pages des spectateurs, `poppins`, `montserrat`, `dm-sans`, `nunito`, `lora` ou `playfair` (titres seulement) ; absente ou inconnue : celles de la plateforme. `shareImageUrl` : l'image d'aperçu de tous les liens du webinaire partagés (inscription, salle d'attente, direct, replay, liens personnels, pages HTML hébergées), servie telle quelle : JPEG ou PNG en https, 1200 x 630, sous 300 Ko (WhatsApp n'affiche pas les vignettes plus lourdes). L'éditeur la recadre et l'allège au téléversement. Sans elle : une carte au titre, à la date et aux intervenants |
| `offers` | Les champs de `POST /webinars/:id/offers`, plus `cueAtSec` : seconde où elle apparaît dans un automatisé. Avec `price` (`{ amount, currency }`), l'offre se paie sans quitter le direct quand le Stripe du webinaire a sa clé publique (voir « Payer une offre dans la salle ») ; sans prix, le prix s'écrit dans `title` ou `subtitle` et le bouton mène à `ctaUrl`. Par défaut `ctaLabel` « Je réserve ma place », `newTab: true`, `urgencyEnd: 'hide'` (l'offre disparaît à la fin du compte à rebours ; `expired` la montre terminée), `announce: 'both'` (le chat annonce clics et achats) |
| `polls`, `quizzes` | `{ question, options: [{ label, isCorrect? }], cueAtSec? }` (`isCorrect` pour un quiz) |
| `emails` | Les emails et SMS en liste, chacun avec son moment, 20 emails et 10 SMS au plus : fournis, ils **remplacent** ceux par défaut (confirmation, rappels, replay). Champs de `POST /webinars/:id/emails`, voir [Emails](#emails-moments-destinataires-variables). Absents, et sans `sequence` : la séquence par défaut, dans la langue du webinaire |
| `sequence` | Ou la séquence en arbre, avec ses attentes et ses conditions : le corps de `PUT /webinars/:id/sequence`, `{ steps: { before, live, after }, trackOpens? }`, voir [Séquence](#séquence-larbre-des-emails-et-sms). Elle remplace celle par défaut. `emails` ou `sequence`, pas les deux |
| `customPages` | Pages HTML de l'hôte, hébergées par webinaire.ch : `{ register?: '<!doctype html>…', waiting?: '…', thanks?: '…' }`, voir [Pages HTML hébergées](#pages-html-hébergées-par-webinairech). Chaque page fournie est choisie pour ce webinaire |

**Le formulaire** (`config.registration`) : le prénom et l'email sont toujours demandés. Le nom si
`lastName: true` (`lastNameRequired` pour l'exiger) ; le téléphone **sauf** `phone: false`
(`phoneRequired`, `phoneCountry` : pays proposé d'office, `CH`). Puis `fields`, les questions en
plus : `[{ key (lettres, chiffres, _), label, type: 'text' \| 'select' \| 'radio' \| 'checkbox', required, options? }]`
(`options` pour `select` et `radio`). `buttonText` : le bouton (défaut « Réserver ma place », dans
la langue du webinaire). `doubleOptIn: true` : l'inscrit confirme son email avant de recevoir quoi
que ce soit. `confirmationDelayMinutes` (1 à 60) : confirmation différée, pour une page de l'hôte
qui fait d'abord rejoindre un groupe ou ajouter le direct à l'agenda. La confirmation part quand
cette page dit l'inscription terminée (`POST /api/public/webinars/:id/registrations/:registrationId/ready`),
au plus tard au bout du délai. Elle part tout de suite à moins du délai plus 15 minutes du direct,
pour un automatisé, ou avec `doubleOptIn`.

Réponse : `{ webinarId, created, urls: { register, waiting, live, studio, edit }, warnings }`. Les scénarios d'avant sont
devenus la séquence : un brief qui porte `scenarios` est refusé.
`warnings` : ce qui passe mais mérite l'œil de l'hôte (par exemple une séquence sans email à l'inscription).

Un brief complet, prêt à envoyer (`orgId` à remplacer) :

```json
{
  "orgId": "ORG_ID",
  "idempotencyKey": "vendre-sans-publicite-2026-11",
  "webinar": {
    "title": "Vendre sans publicité",
    "subtitle": "La méthode en 5 étapes pour remplir votre agenda",
    "type": "live",
    "scheduledAt": "2026-11-12T19:00",
    "timezone": "Europe/Zurich",
    "plannedDurationSec": 3600
  },
  "config": {
    "language": "fr",
    "presenters": [{ "name": "Camille Martin", "role": "Coach en vente" }],
    "registration": {
      "phone": true,
      "phoneCountry": "CH",
      "fields": [
        { "key": "activite", "label": "Votre activité", "type": "select", "required": true, "options": ["Coach", "Consultant", "Formateur", "Autre"] }
      ],
      "buttonText": "Je réserve ma place"
    },
    "waiting": {
      "description": "Trois méthodes concrètes pour trouver des clients sans budget publicitaire.",
      "agenda": ["Le bon sujet", "La structure qui vend", "Questions et réponses"]
    },
    "emailVars": { "lien_offre": "https://exemple.ch/accompagnement" }
  },
  "offers": [
    { "title": "Accompagnement 3 mois", "subtitle": "10 places", "ctaLabel": "Je réserve", "ctaUrl": "https://exemple.ch/accompagnement", "scarcityUnits": 10 }
  ],
  "polls": [
    { "question": "Où en êtes-vous ?", "options": [{ "label": "Je démarre" }, { "label": "J'ai déjà des clients" }] }
  ],
  "sequence": {
    "steps": {
      "before": [
        { "type": "email", "subject": "C'est confirmé : {{titre}}", "body": "# Votre place est réservée\nBonjour {{prenom}}, rendez-vous le **{{date}}**.", "buttonLabel": "Accéder à la salle d'attente" },
        {
          "type": "condition", "test": { "field": "answer", "question": "activite", "value": "Coach" },
          "yes": [{ "type": "email", "subject": "Pour les coachs", "body": "Bonjour {{prenom}}, notez votre plus grande question : on y répondra en direct." }],
          "no": []
        },
        { "type": "wait", "mode": "moment", "moment": "before_start", "minutes": 1440 },
        { "type": "email", "subject": "Demain : {{titre}}", "body": "Bonjour {{prenom}}, c'est demain à {{heure}}." },
        { "type": "wait", "mode": "moment", "moment": "before_start", "minutes": 60 },
        { "type": "email", "subject": "Dans 1 heure : {{titre}}", "body": "Bonjour {{prenom}}, on commence dans {{delai}}." },
        { "type": "sms", "body": "{{prenom}}, {{titre}} commence dans 1 heure : {{lien}}" }
      ],
      "live": [
        { "type": "email", "subject": "C'est en direct", "body": "Bonjour {{prenom}}, **{{titre}}** vient de commencer.", "buttonLabel": "Rejoindre le direct" },
        { "type": "wait", "mode": "delay", "minutes": 15 },
        { "type": "condition", "test": { "field": "attended_live" }, "yes": [], "no": [{ "type": "sms", "body": "{{prenom}}, on a commencé : {{lien}}" }] }
      ],
      "after": [
        { "type": "wait", "mode": "moment", "moment": "after_end", "minutes": 120 },
        {
          "type": "condition", "test": { "field": "attended" },
          "yes": [{ "id": "merciloffre", "type": "email", "subject": "Merci pour votre présence, {{prenom}}", "body": "L'offre dont nous avons parlé est ici.", "buttonLabel": "Voir l'offre", "buttonUrl": "{{lien_offre}}", "excludeBuyers": true }],
          "no": [{ "type": "email", "subject": "Vous l'avez manqué ?", "body": "Bonjour {{prenom}}, le replay vous attend.", "buttonLabel": "Voir le replay", "buttonUrl": "{{lien_replay}}" }]
        },
        { "type": "wait", "mode": "delay", "minutes": 1440 },
        {
          "type": "condition", "test": { "field": "clicked", "step": "merciloffre" },
          "yes": [],
          "no": [{ "type": "email", "subject": "Dernier jour", "body": "Bonjour {{prenom}}, l'offre ferme ce soir.", "buttonLabel": "Voir l'offre", "buttonUrl": "{{lien_offre}}", "excludeBuyers": true }]
        }
      ]
    }
  }
}
```

## Emails : moments, destinataires, variables

Un email de la séquence : `{ trigger, offsetMinutes?, audience?, audienceMinutes?, excludeBuyers?, subject, format?: 'simple' \| 'html', body, buttonLabel?, buttonUrl?, enabled? }`.
Sans `buttonUrl`, le bouton mène au lien personnel de l'inscrit (la bonne page au bon moment).

| `trigger` | Part | `offsetMinutes` |
|---|---|---|
| `registration` | à l'inscription | 0 |
| `after_registration` | N minutes après l'inscription | 1 à 43 200 (30 jours) |
| `before_start` | N minutes avant le début | 5 à 43 200 |
| `live_start` | au lancement du direct | 0 |
| `during` | N minutes après le début, pendant le direct (pas après sa fin, ni après un lancement sans prévenir les inscrits) | 1 à 720 |
| `after_start` | N minutes après le début, une fois le direct fini | 0 à 43 200 |
| `after_end` | N minutes après la fin | 0 à 43 200 |
| `replay_ready` | N minutes après que le replay est prêt | 0 à 43 200 |
| `replay_expiring` | N minutes avant la fin du replay | 15 à 43 200 |

Automatisé à la demande (`type: 'evergreen'`) : `registration` et `after_registration` seulement.
Automatisé à heure fixe (`config.evergreen.schedule.mode: 'sessions'`) : en plus `before_start`,
`live_start` et `after_end`, calés sur la séance de chacun.

`audience` (à partir de `after_registration`, `during`, `after_start`, `after_end`, `replay_ready`,
`replay_expiring` ; avant, tout le monde) : `all`, `attended` (a regardé, direct ou replay),
`absent` (n'a rien regardé), `attended_live`, `attended_replay`, `missed_live` (pas venu au
direct), `bought`, `stayed` (a regardé au moins `audienceMinutes`, direct et replay cumulés), `left_early` (parti du direct
avant `audienceMinutes`), `left_before_offer`, `saw_offer`, `clicked_offer`. `excludeBuyers: true` :
sauf les acheteurs. Un automatisé n'a pas de direct : ni `attended_live`, `attended_replay`,
`missed_live`, `left_early`, `left_before_offer`.

Variables (objet, texte, bouton), écrites dans la langue du webinaire :

| Variable | Donne |
|---|---|
| `{{prenom}}`, `{{nom}}`, `{{email}}` | l'inscrit |
| `{{titre}}`, `{{description}}` | le titre et le sous-titre du webinaire |
| `{{presentateur}}` | les intervenants (« Camille, Jo et Léa »), `{{organisateur}}` : l'organisation |
| `{{date}}` | la date **avec** l'heure et le fuseau : « jeudi 3 décembre à 18:00 (heure de Paris) ». Ne pas y ajouter `{{heure}}` |
| `{{heure}}` | l'heure seule : « 18:00 » |
| `{{delai}}` | le temps restant avant le début : « 1 heure », « 2 jours » |
| `{{lien}}` | le lien personnel de l'inscrit, vers la bonne page du moment (salle d'attente, direct, replay) |
| `{{lien_replay}}` | son lien personnel vers le replay |
| `{{fin_replay}}`, `{{delai_replay}}` | la fin du replay (date) et le temps restant ; vides si le replay ne ferme pas |
| `{{…}}` de `config.emailVars` | les variables propres du webinaire |

Une variable inconnue est refusée (400, avec la liste des possibles). `POST /webinars/:id/emails/preview`
montre le rendu avec un inscrit d'exemple : c'est là qu'on voit une date écrite deux fois.

Format `simple` : `# titre`, `- liste`, `1. liste`, `**gras**`, `*italique*`, `[texte](lien)`,
`![description](image https)` seule sur sa ligne. `html` : un email HTML complet (le pied de
désinscription est ajouté).

**SMS.** `channel: 'sms'` (fixé à la création, `email` par défaut) : un SMS de la séquence, avec les
mêmes moments, délais et destinataires qu'un email. Son texte est `body` (480 caractères au plus),
sans objet, format ni bouton. Variables : `{{prenom}}`, `{{nom}}`, `{{titre}}`, `{{date}}`,
`{{heure}}`, `{{lien}}` et `{{lien_replay}}`, les deux liens étant le lien personnel court (`/j/…`).
10 SMS au plus par webinaire, en plus des 20 emails. Ils partent du compte Twilio de l'organisation
(page Intégrations, voir `docs/integrations.md`) : tant qu'il n'est pas branché, ils attendent.
Reçoivent un SMS les inscrits qui ont donné leur téléphone, pas retirés du webinaire, et qui
recevraient l'email (pas désinscrits, adresse confirmée si la double confirmation est demandée).
Le numéro est mis au format international avec le pays de l'inscrit : sans indicatif ni pays, rien
ne part. « Mes emails partent de mon outil » ne met en pause que les emails. Un SMS « à
l'inscription » ne part plus pendant ni après le direct (son texte annonce un direct à venir). Un
refus de Twilio qui tient au numéro (invalide, pas un mobile, STOP) n'est pas retenté, une panne
l'est, et la dernière erreur s'affiche sur la carte Twilio.

## Séquence : l'arbre des emails et SMS

Une séquence par webinaire : ses emails et SMS rangés en trois temps, `before` (dès l'inscription),
`live` (dès le lancement du direct) et `after` (dès sa fin), avec des attentes et des conditions.
L'éditeur l'ouvre depuis Avant le direct › Emails et SMS, et depuis Après le direct › Relances. Un
automatisé à la demande n'a que `before` (l'après-inscription) ; un automatisé à heure fixe a les
trois, calés sur la séance de chacun.

| Méthode | Route | Rôle |
|---|---|---|
| GET | `/webinars/:id/sequence` | `{ kind, trackOpens, steps: { before, live, after }, stats, hidden, limits, status }`. Les messages y sont avec leur texte. `stats[id]` : `{ sent, opened, clicked }`. `hidden` : messages d'un autre format de webinaire, gardés de côté |
| PUT | `/webinars/:id/sequence` | `{ steps: { before, live, after }, trackOpens? }`, l'arbre entier. Chaque message reçoit son moment et ses conditions, les nouveaux sont créés, ceux qui n'y sont plus retirés. Tout ou rien (400 : l'étape à corriger). Renvoie la séquence comme GET |

Étapes :

- **Email** `{ type: 'email', id?, enabled?, subject, format?, body, buttonLabel?, buttonUrl?, excludeBuyers? }`,
  **SMS** `{ type: 'sms', id?, enabled?, body, excludeBuyers? }` : les règles et variables des
  [emails et SMS](#emails-moments-destinataires-variables). `id` : celui que GET a rendu. Un nouveau
  message s'en passe, ou en prend un court (6 à 24 lettres minuscules et chiffres) quand une
  condition le vise. Sans `body`, un message garde son texte enregistré : `{ id, type, enabled }`
  suffit pour le déplacer ou le couper. Un message coupé (`enabled: false`) peut rester à écrire ;
  un message actif doit être complet. `excludeBuyers` : pas à ceux qui ont acheté. 20 emails et
  10 SMS au plus.
- **Attente** `{ type: 'wait', mode: 'delay', minutes }` (1 à 43 200 ; pendant le direct, 12 heures
  au plus après son début), ou `{ type: 'wait', mode: 'moment', moment, minutes }` : dans `before`,
  `before_start` (N avant le début, 5 à 43 200) ; dans `after`, `after_end` (N après la fin),
  `replay_ready` (N après la mise en ligne du replay), `replay_expiring` (N avant la fin du replay,
  15 à 43 200). Une attente fixe le moment des messages qui la suivent ; une durée s'ajoute au
  moment de l'étape du dessus. Un moment ne remonte pas le temps, et une attente avant le direct ne
  mène pas à moins de 5 minutes de son début.
- **Condition** `{ type: 'condition', id?, test: { field, … }, yes: [étapes], no: [étapes] }`. Après
  elle, la suite reprend pour tout le monde, au moment de la condition. `field` : `attended` (a
  regardé, direct ou replay), `attended_live`, `attended_replay`, `stayed` (`minutes` : au moins N
  minutes, direct et replay cumulés), `left_early` (`minutes`), `left_before_offer`, `saw_offer`,
  `clicked_offer`, `bought`, `answer` (`question` : la `key` d'un champ du formulaire, `value` : la
  réponse), `source` (`value` : la source utm), `opened` et `clicked` (`step` : l'`id` d'un email
  placé plus haut). Avant le direct, personne n'a regardé : seulement `answer`, `source`, `bought`,
  `opened`, `clicked`. Pendant : en plus `attended_live` (« est déjà dans le direct »), `attended`,
  `stayed`, `saw_offer`, `clicked_offer`. Après : toutes. 6 conditions l'une dans l'autre et 150
  étapes au plus.

La confirmation est le premier email de `before`, hors de toute condition : elle part à chaque
inscription avec l'invitation d'agenda, et tient lieu de « C'est en direct » ou du replay pour qui
s'inscrit pendant ou après. Une condition est lue au moment où chaque message part : quelqu'un qui
regarde le replay en retard passe du côté « a regardé » pour les messages suivants. Une personne
inscrite après le moment d'un message ne le reçoit pas. Ce qui est déjà parti n'est jamais renvoyé,
même déplacé.

Une fois la séquence enregistrée, ses emails sont suivis : chaque lien passe par une redirection
signée (le clic est noté, puis l'inscrit arrive à l'adresse d'origine), et un pixel estime les
ouvertures (`trackOpens: false` le coupe). La désinscription reste un lien direct.

Les scénarios d'avant (arbres séparés) sont devenus la séquence : un brief ou un
`POST /webinars/:id/scenarios` qui en crée est refusé. Ceux qui existent encore continuent.

**Condition sur le CRM** : `{ field: 'crm', question: <clé de la propriété>, value }`, vraie quand la valeur
de l'inscrit pour cette propriété (`PATCH /webinars/:id/registrations/:registrationId/crm`) vaut `value`,
sans égard aux majuscules ni aux espaces autour. Une case cochée vaut `true`, décochée `false` ; jamais
touchée, elle n'a pas de valeur. Lue au moment où chaque message part : l'équipe tient ces valeurs dans
Résultats (ou par l'API), et un email ou un SMS peut se taire selon elles (branche « Non »). L'éditeur ne la
propose pas, mais garde lisible une condition posée par l'API.

## Authentification

Toutes les routes demandent une clé d'API ou une session, sauf celles marquées « Public ».
Une personne se connecte (`/auth/login`) et envoie `Authorization: Bearer <jeton>` ; un agent ou un
script envoie `x-api-key: wsk_…`.

| Méthode | Route | Corps | Réponse |
|---|---|---|---|
| POST | `/auth/signup` | `{ email, password(8+), fullName?, orgName?, invite?, account? }` | `{ token, user{ id, email, full_name, orgId } }`. Inscriptions fermées : seulement avec le jeton d'une invitation, `invite` (équipe d'un webinaire) ou `account` (compte ouvert par l'équipe webinaire.ch, l'organisation naît avec sa formule), et l'adresse invitée |
| POST | `/auth/login` | `{ email, password }` | `{ token, user }` (401 si invalide) |
| GET | `/auth/me` | clé ou jeton | `{ id, email, full_name, language, orgs: [{ id, name, role, canBroadcast }], team }` (`team` : équipe webinaire.ch, jamais avec une clé) |
| POST | `/auth/refresh` | header `Authorization: Bearer <token>` | `{ token }` (7 jours de plus ; la régie l'appelle en s'ouvrant). Refusé à une clé d'API |
| POST | `/auth/forgot` | `{ email }` | `200 { ok: true, sending }`, que le compte existe ou non (`sending: false` : aucun fournisseur d'email, rien ne peut partir) ; lien par email (60 min, usage unique, une nouvelle demande n'annule pas les précédents, le changement les brûle tous). 10 demandes par adresse IP et par quart d'heure ; emails : 3 par heure pour une même adresse IP, et depuis les adresses inconnues du compte, 10 par heure et 20 par jour |
| POST | `/auth/reset` | `{ token, password(8+) }` | `200 { token, user, revokedKeys }` : nouvelle session. Les sessions ouvertes avant (API, régie) sont refusées, et les clés d'API créées depuis ce compte révoquées (`revokedKeys`) ; celles des autres administrateurs restent. `400` si le lien a servi, est périmé ou a été remplacé |
| GET, POST | `/orgs/:orgId/api-keys` | `POST { name? }` | Propriétaire ou administrateur, en personne (pas avec une clé) : `POST` → `{ id, name, prefix, created_at, key }`, la clé en clair une seule fois ; `GET` → les clés, sans elle (`prefix`, `last_used_at`, `revoked_at`) |
| DELETE | `/orgs/:orgId/api-keys/:keyId` | | Révoque la clé : ce qu'elle avait ouvert perd son accès avec elle |

Jeton signé avec `JWT_SECRET` (7 jours). Une clé d'API agit comme un propriétaire de son
organisation, dans cette organisation seulement.

## Organisations & webinaires

| Méthode | Route | Rôle |
|---|---|---|
| POST | `/orgs` | `{ name, slug }` → org |
| POST | `/webinars` | `{ orgId, title, subtitle?, type?, scheduledAt?, plannedDurationSec? }` |
| GET | `/webinars` | Les webinaires de la personne connectée (toutes ses organisations, et ceux où elle est invitée), la date principale seule pour un webinaire à plusieurs dates : `{ id, title, type, status, scheduled_at, registrations, sales, section, archived, … }` |
| PATCH | `/webinars/:id/organize` | Équipe de l'organisation : ranger le webinaire dans la liste. `{ section?, archived? }` : `section`, un nom de 40 caractères au plus (`null` ou `""` : sans section) ; `archived: true` le met dans « Archivés » (refusé en direct, `409`), `false` l'en sort. Ses autres dates suivent. Archivé, il ne disparaît pas : ses pages, son replay et ses emails continuent. → `{ section, archived }` |
| GET, POST, PATCH, DELETE | `/orgs/:orgId/sections` | Équipe de l'organisation : les sections de la liste des webinaires, même vides, par ordre alphabétique. `GET` → `{ sections: [{ name, webinars }] }` ; `POST { name }` en crée une (40 caractères au plus, `409` si elle existe déjà, sans égard aux majuscules) ; `PATCH { from, to }` la renomme, ses webinaires suivent ; `DELETE ?name=` la supprime, ses webinaires restent sans section. Ranger un webinaire dans une section qui n'existe pas encore (`PATCH /webinars/:id/organize`) la crée |
| GET | `/webinars/:id` | Détail (inclut `config`, `stream_key`, `chat_blocked_words` : les mots interdits du chat, pour l'équipe seulement) ; `organizer` : nom montré aux spectateurs (`config.legal.organizer`, sinon celui de l'organisation). Pour l'équipe, `orgMember` : faux pour une personne invitée sur ce webinaire seulement |
| PATCH | `/webinars/:id` | Champs cœur (titre, date, durée, type) et mots interdits du chat : `blockedWords`, un tableau ou un texte (un mot ou une expression par ligne, ou séparés par des virgules), `*` final pour les variantes (`arnaq*` : arnaque, arnaqueur), sans égard aux majuscules, aux accents ni aux chiffres mis pour des lettres ; 1 000 au plus, 60 caractères chacun ; `null` ou `[]` : aucun. Chaque webinaire a sa liste, relue dans `GET /webinars/:id` (`chat_blocked_words`, nettoyée : minuscules, sans doublon). Un message de spectateur qui en contient un ne va qu'à son auteur et à l'équipe, marqué filtré, jamais une question ; l'équipe peut le publier (voir [WebSocket](#websocket-socketio-même-port)). Le chat en tient compte en 15 secondes au plus. Une copie du webinaire, ses autres dates et l'automatisé tiré du direct reprennent sa liste. Un nouveau webinaire n'en a aucune |
| PATCH | `/webinars/:id/config` | Par onglet : chaque clé de premier niveau envoyée (`registration`, `waiting`, `live`…) remplace l'onglet enregistré **en entier** ; les autres ne bougent pas. Renvoyer l'onglet complet, relu dans `GET /webinars/:id`. `live: { joinText?, posterUrl?, clickToStart?, fullscreen?, links?: [{ label (40 car.), url (https) }] (5 au plus), countdownColor? (#rrggbb) }` : salle du direct (cliquer pour démarrer, bouton plein écran, liens sous la vidéo, fond du décompte du lancement, noir par défaut). `replay: { …, expiresAfterHours? (1 h à 1 an), expiresFrom?: 'end' \| 'registration', expiresAt? (date ISO), countdown?, questions? }` : fin du replay après le direct, pour chaque inscrit (à partir de son inscription s'il arrive après), ou à une date ; compte à rebours ; questions pendant le replay (ouvertes par défaut). `sender: { fromName? (80 car.), replyTo? (email) }` : expéditeur de ce webinaire (nom affiché et réponses ; l'adresse d'envoi reste celle de l'organisation ; jamais montré sur la page publique). `pages` et `notify` : voir [Pages sur le site de l'hôte](#pages-sur-le-site-de-lhôte-api-publique). `tracking: { metaPixelId (chiffres), consent: 'ask' \| 'none' }` ('ask' : accord demandé avant de charger le pixel ; 'none' : pixel chargé, bandeau qui informe avec « Refuser » ; un refus enregistré vaut dans les deux cas) et `legal: { privacyUrl (https), organizer (120 car.) }` sont vérifiés (400 lisible). `live.chatMode` : `public`, `private`, `moderated` ou `off`, voir [WebSocket](#websocket-socketio-même-port). `presenters: [{ name, role?, photoUrl? (https), social? }]` (4 au plus, dans l'ordre de la page) : le premier devient `presenter`, et `{{presentateur}}` les nomme tous (« Camille, Jo et Léa ») ; `social` : un compte Instagram ou LinkedIn sous le nom, `@pseudo` ou un lien (https ajouté). `registration: { …, doubleOptIn?, autoSubscribe?, layout?, abTest? }` : double confirmation de l'email, case « Me prévenir des prochains webinaires », mise en page (`split`, `centered`, `cover`, `minimal`) et test A/B (`{ enabled, id (6 à 16 caractères a-z0-9, nouveau à chaque test), title? (200 car.), imageUrl? (https) }`). `waiting.layout` : `centered`, `split`, `cover`, `minimal`. `evergreen: { videoUrl?, showCount?, chat?: [{ id?, authorName, body, videoTimecodeSec, fromHost?, kind?: 'message' \| 'question' \| 'answer' \| 'highlight', replyTo? (id d'une question) }] (3000 au plus), chatMode?: 'script' \| 'questions' \| 'off', questions?, liveChat?: 'off' \| 'team' \| 'session', timeline?, schedule? }` : vidéo, vrai nombre de spectateurs (à partir de 2), messages de l'automatisé, chat (messages, questions à l'animateur seulement, ou aucun). `liveChat` : les spectateurs écrivent à l'équipe (`team`) ou à toute leur séance (`session`), voir [Chat d'un automatisé](#chat-dun-automatisé). `timeline` (200 moments au plus) : `[{ id, kind: 'offer' \| 'poll' \| 'quiz' \| 'file' \| 'announcement' \| 'pin' \| 'redirect', at (seconde), until?, ref? (offre, sondage ou quiz de ce webinaire), label?, url? (https), text? }]`, ce qui apparaît à la seconde de la vidéo ; `replay.timeline` fait de même pour un replay remplacé par une autre vidéo (`offer`, `poll`, `quiz`, `file`, `pin`). `schedule` : séances à heure fixe, voir [Automatisé à heure fixe](#automatisé-à-heure-fixe). `access: { open?, capacity?, overflowUrl? }` : entrée au prénom seulement, sans email ; places dans la salle du direct (1 à 100 000, `null` sans limite : au-delà, `presence:join` répond `{ full: true }` pendant le direct, un inscrit sur deux appareils compte une fois, ni la salle d'attente ni le replay ne sont limités) et lien (https) montré à ceux qui n'ont pas de place. `series: { mode: 'choice' \| 'all' }` : plusieurs dates, voir `/webinars/:id/dates`. `room: { permanent? }` : salle permanente, le même lien pour toutes les diffusions, voir `next-broadcast` |
| GET, POST, PATCH, DELETE | `/orgs/:orgId/sms` | Équipe de l'organisation : Twilio pour les SMS, celui du compte et celui de chaque section (voir `docs/integrations.md`, SMS (Twilio)). `GET` → `{ account, sections: [{ name, sms }] }` ; `POST { accountSid, token, from, section? }` ou `{ section, sameAccount: true, from }` (`400` si cette portée a déjà son Twilio) ; `PATCH /orgs/:orgId/sms/:id { messages }` ; `DELETE /orgs/:orgId/sms/:id` |
| GET, PATCH | `/orgs/:orgId/settings` | `{ retentionMonths, subscribers, plan }` : données des inscrits gardées 6, 12, 24 ou 36 mois après l'inscription (`null` : sans limite ; effacées chaque heure, les désinscriptions restent), nombre d'abonnés aux prochains webinaires, et `plan: { id, limits, usage: { team, viewerHours } }` (lecture seule). Lecture pour l'équipe, modification owner/admin. Les mots interdits du chat se règlent dans chaque webinaire (`PATCH /webinars/:id`, `blockedWords`) : envoyés ici, `400` |
| GET | `/plans` | Public : les formules `{ currency: 'EUR', plans: [{ id, name, tagline, monthly, yearly, attendees, presenters, minutes, team, viewerHours, highlight? }] }`. `attendees` : spectateurs en même temps dans la salle ; `presenters` : personnes à l'image, animateur compris ; `minutes` : durée d'un direct (la régie prévient, ne coupe pas) ; `team` : personnes invitées sur les webinaires de l'organisation ; `viewerHours` : visionnage par mois |
| PATCH | `/admin/orgs/:orgId/plan` | Équipe webinaire.ch seulement (jamais avec une clé d'API) : `{ plan }` parmi `essentiel`, `croissance`, `professionnel`, `entreprise`, `pro` (sans limite) ou `free` (pas activé) |
| GET | `/admin/orgs`, `/admin/invites` | Équipe webinaire.ch seulement : les organisations (`{ id, name, plan, createdAt, owner, teamGuest, webinars, viewerHours }`, visionnage du mois) et les invitations à créer un compte pas encore utilisées |
| POST, DELETE | `/admin/invites`, `/admin/invites/:id` | Équipe webinaire.ch seulement : `POST { email, plan }` (une formule ou `pro`) envoie un lien de création de compte valable 14 jours (renvoyer : lien neuf, l'ancien ne marche plus) ; `409` si l'adresse a déjà un compte. `DELETE` annule |
| GET | `/auth/account-invite?t=` | Public : `{ email, plan }` d'un lien d'invitation encore valable, sinon `404` |
| GET, POST | `/webinars/:id/members` | Équipe de l'organisation : les personnes invitées sur ce webinaire `[{ id, email, name, status: 'active' \| 'invited' \| 'expired' }]`. `POST { email }` : avec un compte, accès immédiat ; sans compte, un email avec un lien pour en créer un (14 jours, même quand les inscriptions sont fermées, pour cette adresse seulement) → `{ …, emailed }`. Une personne invitée pilote ce webinaire et les autres dates de sa série (éditeur, régie, résultats), sans gérer cette liste ni archiver le webinaire. 25 par webinaire ; la formule limite le nombre de personnes sur toute l'organisation (402) |
| DELETE | `/webinars/:id/members/:memberId` | Équipe de l'organisation : retire l'accès aussitôt |
| GET | `/webinars/:id/presenter-suggestions` | Intervenants déjà présentés dans les webinaires de l'organisation (12 au plus, fiche la plus récente) |
| GET | `/webinars/:id/question-suggestions` | Bibliothèque : questions du formulaire et de la salle d'attente des autres webinaires de l'organisation (30 au plus, une par libellé et type, la plus récente) `[{ key, label, type, options, required }]` |
| GET, POST | `/webinars/:id/dates` | Équipe : plusieurs dates d'un même direct `{ mainId, mode, dates: [{ id, scheduled_at, status, registrations, main }] }`. `POST { scheduledAt }` ajoute une date, `POST { weekdays: [1..7], time: 'HH:MM', count (1 à 52) }` des dates récurrentes (à partir de demain, heure du fuseau du webinaire, heure d'été comprise ; 60 dates au plus). Chaque date est un webinaire lié (sa régie, ses inscrits, son replay, ses résultats) qui reprend réglages, emails, offres, sondages et quiz de la date principale tant qu'il n'a pas commencé. `GET /webinars/:id` public renvoie `series: { mainId, mode, dates: [{ id, scheduledAt, status }] }` (dates à venir ou en direct) |
| DELETE | `/webinars/:id/dates/:dateId` | Équipe : retire une date sans inscrit, pas en direct |
| POST | `/webinars/:id/next-broadcast` | Équipe, salle permanente (`config.room.permanent`) : `{ scheduledAt? }` range la diffusion terminée (enregistrement, chat, journal de la régie, questions, marqués `broadcast_id`) et rouvre la salle pour la suivante avec le même lien (`published`, emails du moment à nouveau dus). 409 hors salle permanente, pendant une diffusion, ou tant que son replay se prépare |
| GET | `/webinars/:id/broadcasts?r=` | Public : diffusions passées d'une salle permanente `{ broadcasts: [{ id, startedAt, endedAt, durationSec, parts: [{ id, hls, startedAt, durationSec }] }] }`, les plus récentes d'abord, avec les mêmes règles que le replay (coupé, pas enregistré, délai après la fin de chacune, mot de passe de la salle) |
| POST | `/webinars/:id/evergreen-copy` | Équipe : un direct terminé devient un automatisé neuf (sa vidéo enregistrée, le chat du direct à sa minute, offres, sondages, quiz, documents et messages épinglés au moment où la régie les avait montrés, emails d'un automatisé neuf) → `{ id }`. 409 tant que la vidéo n'est pas prête |
| POST | `/webinars/:id/start` | Passe en live. `{ notify?: boolean }` (oui par défaut) : les emails « au lancement du direct » (« C'est en direct ») partent aux inscrits ; `false`, rien ne part : ni les messages du lancement, ni ceux de « Pendant le direct ». 409 pour un webinaire automatisé |
| POST | `/webinars/:id/end` | Termine + **fige les scores** + prévient tous les spectateurs (`webinar:ended`) |
| POST | `/webinars/:id/resume` | Reprend un direct terminé, à tout moment : le même direct (statut `live`, même `actual_started_at`), avec son chat, ses votes, ses présences et son enregistrement (le replay enchaîne les morceaux). Le temps hors antenne (`config.launch.pausedSec`) ne compte pas dans `actual_duration_sec`. `{ notify?: boolean }` (non par défaut) : « C'est en direct » repart pour tous les inscrits, même ceux qui l'avaient reçu. Les autres emails et envois d'après le direct attendent la nouvelle fin ; ceux déjà partis ne repartent pas. Les spectateurs restés sur la page, replay compris, reviennent au direct (`webinar:resumed`, et à la reconnexion si elle a sauté). `{ webinar }` ; 409 si le webinaire n'est pas un direct terminé |

## Inscriptions & contenu

| Méthode | Route | Rôle |
|---|---|---|
| GET | `/webinars/:id/survey/:registrationId` | Public (lien personnel) : questions de la salle d'attente déjà répondues `{ answered: [clé] }` |
| POST | `/webinars/:id/survey/:registrationId` | Public : `{ answers: { <clé>: réponse } }`, vérifiées comme celles du formulaire, rangées avec elles (Résultats, export) |
| POST | `/webinars/:id/register` | `{ email, firstName?, lastName?, phone?, country?, custom?, utm?, ads?, subscribe?, ab?, sessionAt?, tz?, turnstile? }` (+ email de confirmation, intégrations). `turnstile` : jeton du captcha invisible, exigé quand le serveur a ses deux clés (`GET /webinars/:id` donne alors `turnstileSiteKey` ; 400 sans jeton ou jeton refusé ; Cloudflare en panne : accepté). Même règle pour `POST /webinars/:id/guest`. Webinaire payant (`config.registration.payment`) : rien n'est inscrit, la réponse est `{ checkout: { url, amount, currency } }` (la page de paiement Stripe de l'hôte) ou `{ alreadyRegistered: true }` (adresse déjà inscrite : son lien est dans son email, jamais dans la réponse) ; 400 si Stripe n'est pas branché. `POST /webinars/:id/guest` est alors refusé (403). `tz` : fuseau du visiteur (`Europe/Paris`). `sessionAt` : séance choisie d'un automatisé à heure fixe (400 « Cette séance n’est plus proposée » si elle ne l'est pas) ; sans elle, la prochaine. `ads: { consent: true, fbp?, fbc? }` : suivi publicitaire accepté sur la page, l'inscription part aussi par l'API de conversions de Meta. `subscribe: true` : case « Me prévenir des prochains webinaires » cochée (prise en compte si `registration.autoSubscribe`). `ab: { test, variant: 'a' \| 'b' }` : version de la page vue pendant un test A/B, gardée à la première inscription. Double confirmation : seule la demande part (une fois ; une réinscription dix minutes plus tard la renvoie), et les outils branchés reçoivent l'inscription à la confirmation. Pas demandée quand elle ne pourrait pas arriver (compte gratuit, emails envoyés par l'outil de l'hôte, aucun service d'envoi) : l'inscrit compte confirmé |
| GET | `/webinars/:id/registrations/:registrationId` | Public : `{ id, first_name, pending }` (lien personnel `?r=` des emails et de l'agenda), sinon 404. `pending` : adresse pas encore confirmée (double confirmation) |
| POST | `/webinars/:id/guest` | Public, salle ouverte au prénom (`access.open`) : `{ firstName, utm? }` → `{ id, first_name }`, une inscription sans email (ni email, ni alerte, ni outil branché). 403 sinon |
| GET, POST | `/registrations/confirm` | Public : `GET ?t=` → `{ title, firstName, confirmed }` ; `POST { t }` confirme (jamais un GET : les antivirus ouvrent les liens), puis la confirmation part et les outils reçoivent l'inscription → `{ confirmed, title, firstName, link }`. `t` : jeton signé de l'email, page `/confirmation?t=` |
| GET, POST | `/invitations` | Public : `GET ?t=` → `{ title, firstName, registered }` ; `POST { t }` inscrit l'abonné invité d'un clic (adresse comptée confirmée) → `{ id, link }`, ou `{ needsForm: true, message, link }` si le formulaire demande plus (téléphone, question obligatoire). Page `/invitation?t=` |
| GET | `/webinars/:id/access?r=` | Public : `{ protected, granted }`, mot de passe de la salle donné ou non par cet inscrit |
| POST | `/webinars/:id/access` | Public : `{ registrationId, password }` → `{ protected, granted }` ; 401 « Mot de passe incorrect. » (10 essais par adresse IP en 10 minutes). Sans lui : ni adresse de lecture du direct (`stream?r=` renvoie `locked`), ni replay, ni vidéo de l'automatisé (retirée de `GET /webinars/:id` pour le public, qui dit `accessProtected`) |
| GET | `/webinars/:id/access/video?r=` | Public, mot de passe donné : `{ evergreenUrl }` |
| PUT, DELETE | `/webinars/:id/access/password` | Équipe : `{ password }` (4 à 100 caractères, gardé haché, jamais renvoyé) ; le changer le redemande à tout le monde ; DELETE ouvre la salle |
| POST | `/webinars/:id/ab/visit` | Public : `{ test, variant }`, une visite de la page d'inscription pendant le test en cours (le navigateur ne l'envoie qu'une fois par test) |
| GET | `/webinars/:id/ab` | Équipe : `{ test, running, a: { visits, registrations }, b: { … } }` pour le test en cours ou le dernier |
| POST | `/webinars/:id/videos` | Équipe, compte activé : `{ purpose: 'evergreen' \| 'replay', name, size, durationSec }` → `{ id, uploadUrl, url }`. Le navigateur envoie le fichier par tus directement chez Cloudflare Stream (`uploadUrl`), puis `POST /webinars/:id/videos/:videoId/uploaded` ; `GET /webinars/:id/videos/:videoId` suit la préparation (`uploading`, `processing`, `ready`, `error`). Une vidéo qui ne sert plus à aucun webinaire est effacée chez Cloudflare au bout d'un jour |
| GET | `/webinars/:id/replay?r=` | Public : segments, chat et offres du replay. `r` : le lien personnel, pour la fin du replay de CET inscrit ; sans lui, celle de quelqu'un qui s'inscrirait maintenant. `expiresAt` et `countdown` quand le replay a une fin ; `expired: true` une fois fermé |
| GET | `/webinars/:id/chat?limit=&r=` | Public : les derniers messages publics du chat (50 par défaut, 200 au plus). Salle protégée par un mot de passe : `[]` sans un `r` (lien personnel) qui y a accès, sauf pour l'équipe ou avec l'en-tête `x-moderation-key`, comme le chat en direct |
| POST | `/webinars/:id/replay/questions` | Public : `{ registrationId, body (1000 car.), at? (seconde du replay) }`, question à l'hôte pendant le replay (10 par inscrit, 20 par adresse en 10 min). Rangée avec les questions du direct (`fromReplay`, `replaySec` dans Résultats), résumé envoyé à l'hôte toutes les 30 minutes au plus, dix minutes après la question et sans celles répondues entre-temps |
| GET | `/webinars/:id/evergreen/chat?r=` | Public (lien personnel) : chat d'un automatisé avec l'équipe `{ messages }` : ceux de l'inscrit, les réponses de l'équipe, et (`liveChat: 'session'`) ceux de sa séance. Vide si le chat avec l'équipe est coupé |
| GET | `/webinars/:id/evergreen/comments` | Équipe : les 200 derniers vrais messages des spectateurs d'un automatisé `[{ id, authorName, body, videoTimecodeSec, createdAt, question }]`, à ajouter au chat scripté |
| GET | `/webinars/:id/date-notice` | Équipe : direct déplacé après des inscriptions `{ scheduledAt, due, count, announced: { at, sent, total } \| null, blocked }`. `due` : `count` inscrits (avant le dernier changement de date, joignables) connaissent une autre date ; `blocked` : pourquoi l'email ne partirait pas |
| POST | `/webinars/:id/date-notice` | Équipe : `{ scheduledAt? }` (la date vue par l'hôte ; déplacée depuis, 400) annonce la date actuelle. Email « Nouvelle date » avec l'invitation d'agenda de la confirmation déplacée (même identifiant, version plus haute), une fois par inscrit et par date ; déplacé encore avant l'envoi, plus rien ne part |
| GET | `/webinars/:id/evergreen?r=` | Public : moments de la vidéo d'un automatisé `{ cues: [{ id, kind, at, until?, data }], offerStock, session, now }` (offres, sondages et quiz relus en base, sans les bonnes réponses ; `{ locked: true }` sans le mot de passe de la salle). `session` : la séance de cet inscrit `{ at, replay, lengthSec }` (automatisé à heure fixe), sinon `null` ; `now` : l'heure du serveur |
| GET | `/webinars/:id/sessions?tz=` | Public : prochaines séances d'un automatisé à heure fixe `{ sessions: [ISO] }`, pour le fuseau `tz` du visiteur (nuit et jours bloqués retirés) |
| POST | `/webinars/:id/session` | Public (lien personnel) : `{ r, sessionAt, tz? }` change la séance de l'inscrit → `{ sessionAt }`. Ses rappels repartent pour la nouvelle heure, avec une nouvelle confirmation |
| POST | `/webinars/:id/media?kind=image\|file&name=` | Équipe (même avant l'activation) : le fichier en corps brut (`application/octet-stream`). Type lu dans le fichier : JPEG, PNG, WebP, GIF (2 Mo) ; PDF, Word, Excel, PowerPoint, ZIP (10 Mo) ; jamais de SVG ni de HTML. 300 Mo par organisation, un même fichier gardé une fois → `{ id, url, name, size, mime }`. Effacé un mois après s'il ne sert nulle part |
| GET | `/media/:id` | Public : le fichier, cache permanent (`immutable`), relayé par gowebi.ch/media/:id |
| POST | `/webinars/:id/slides/pages?name=` | Équipe : une page des slides du direct, en image (corps brut JPEG, PNG ou WebP, 2 Mo au plus ; pas un PDF). Même stockage que `/media`, une même page gardée une fois → `{ id, url, … }`. 600 pages par 10 minutes |
| POST | `/webinars/:id/slides/deck?name=` | Équipe : une présentation HTML d'un seul fichier (corps brut `text/html`, images et polices dedans, 25 Mo au plus) qui expose son moteur dans `window.DECK` : `go(slide, étape, animer)`, `S` (ses slides), `groups(slide)` (ses étapes). Rangée avec les fichiers de l'organisation, un même fichier gardé une fois → `{ id, name, size }`. 20 par 10 minutes |
| PUT | `/webinars/:id/slides` | Équipe : `{ name, pages: [id, …] }`, les images envoyées dans l'ordre (300 au plus, doublons permis), ou `{ kind: 'html', name, deck, total?, steps?: [n, …] }` : le deck envoyé, son nombre de slides (1000 au plus) et les étapes de chacune (révélations au clic, `window.DECK.groups(slide).length`, 0 à 200). Sans `total` ni `steps`, le serveur les lit dans le HTML selon les conventions des decks de webinaire.ch (une slide : `<section class="s">` ; une étape : un groupe `[data-step="n"]`, les éléments de même numéro ensemble, ou, dans un deck sans `data-step`, un élément `.r`, `.hl`, `.rv` ou `[data-ann]` de la slide, sauf `[data-auto]`, `data-with="prev"` joint à l'étape d'avant) et marque les slides `unverified: true` : la régie ou l'éditeur les recomptent dans le moteur du deck à la première ouverture. Remplace les slides d'avant → `{ name, pages, updatedAt }` ou `{ kind: 'html', name, deck, total, steps, labels, updatedAt, unverified? }` (`labels` : la partie de chaque slide, lue dans les notes du deck), rangé dans `config.slides`. La régie les présente depuis n'importe quel ordinateur ; les spectateurs ne les reçoivent jamais (retirées de `GET /webinars/:id` pour le public), ils les voient à l'antenne. `PATCH /config` ne les écrit ni ne les efface |
| GET | `/webinars/:id/slides/notes` | Équipe : les notes du présentateur du deck HTML, une par slide `{ notes: [{ s, t }] }` (`s` : la partie, `t` : le texte), lues dans son `<script id="notes-data">` ; jamais envoyées aux spectateurs |
| GET | `/decks/:id` | Public : le deck HTML tel que les pages le montrent, dans un bac à sable (`Content-Security-Policy: sandbox allow-scripts`, aucune requête vers l'extérieur), ses notes vidées, avec le script de pilotage de la plateforme (il s'arrête hors du bac à sable). Ce script dessine aussi les miniatures de la bande des slides, à la demande de la page (`{ webinaireDeck: 1, cmd: 'thumbs', from, to, w, h }` → un `{ thumb: { i, url } }` par slide, JPEG en data: URL, puis `{ thumbs: 'done' }`) : rien à prévoir dans le deck. `?v=` : la version de ce script attendue par la page (`DECK_SCRIPT_VERSION`, shared/files.ts) ; cache permanent sous cette adresse si c'est celle du serveur, aucun cache sinon. Relayé par gowebi.ch/api/deck/:id |
| DELETE | `/webinars/:id/slides` | Équipe : plus de slides sur la plateforme ; leurs images partent au bout d'un mois |
| GET | `/webinars/:id/content` | Équipe du webinaire, ou en-tête `x-moderation-key` (la clé du lien modérateur) : `{ offers, quizzes, polls, statuses }`. `statuses` : paliers internes du score des inscrits, montrés nulle part (ni aux spectateurs ni dans les exports). Sans l'un ni l'autre : seulement l'offre de la page de remerciement (`config.thanks.offerId`, ses champs publics), le reste vide |
| POST | `/webinars/:id/offers` | Créer une offre : `{ title, subtitle?, ctaUrl?, ctaLabel?, internalName?, imageUrl?, newTab?, prefill?, urgencySec?, urgencyEnd?, scarcityUnits?, announce?, price?, compareAt?, items?, guarantee?, payInRoom?, installments? }`. `price` `{ amount: 497, currency: 'EUR' }` (CHF, EUR, USD, GBP, CAD ; de 1 à 100 000, deux décimales) ou `null` ; `installments` (2 à 24, `null` : en une fois) : payable en plusieurs fois, un paiement par mois, le premier pendant le direct, `price` étant alors chacun (« 3 × 199 € ») ; la vente compte le total dû ; `compareAt` : prix barré, même devise ; `items` : ce qui est inclus, 8 lignes de 120 caractères au plus (tableau, ou texte une ligne par élément) ; `guarantee` : 160 caractères ; `payInRoom` (oui par défaut) : avec un prix et un Stripe prêt, la mini page de l'offre s'ouvre dans la salle ; `false` garde le bouton vers `ctaUrl`. `urgencySec` (60 à 86400) : compte à rebours à partir du moment où la régie montre l'offre ; `urgencyEnd` `hide` ou `expired` ; `scarcityUnits` : places, qui baissent avec les ventes confirmées ; `announce` `both`, `purchases`, `clicks` ou `off` (ce que le chat annonce) |
| PATCH | `/webinars/:id/offers/:offerId` | Mêmes champs ; seuls ceux envoyés changent, `null` efface |
| POST | `/webinars/:id/offers/:offerId/pay` | Public, depuis la salle : le clic sur « Payer ». `{ registrationId, amountCents, currency, installments? }` (le prix affiché) → `{ paymentId, clientSecret, billing }` : un PaymentIntent sur le Stripe du webinaire, au prix de la base ; en plusieurs fois, celui de la première facture d'un abonnement mensuel qui s'arrête de lui-même après le dernier paiement (client, prix et abonnement créés chez Stripe : la clé doit écrire sur Customers, Products, Prices et Subscriptions). Prix changé : 409 avec la mini page à jour (`checkout`), rien n'est créé. Déjà payée par cet inscrit : `{ status: 'paid' }` |
| POST | `/webinars/:id/offers/:offerId/pay/:paymentId/redirect` | Public : Klarna dans un nouvel onglet. `{ registrationId, paymentMethod: 'pm_…' }` (le moyen créé par le formulaire, `stripe.createPaymentMethod`) : le PaymentIntent est confirmé par le serveur, Klarna ramènera l'onglet sur `/w/:id/paiement-termine`, qui prévient l'onglet du direct (BroadcastChannel `webinaire-paiement`) → `{ url }` (la page de Klarna), ou `{ status: 'paid' }` ; 400 si Klarna refuse |
| POST | `/webinars/:id/offers/:offerId/pay/:paymentId/confirm` | Public : `{ registrationId }` → `{ status: 'paid' \| 'processing' \| 'open' \| 'failed' }`. Payé : la vente est comptée une fois (chat, rareté, statut d'acheteur, outils de l'hôte). Une passe toutes les minutes rattrape les paiements confirmés sans retour de la page |
| GET | `/webinars/:id/offers/:offerId/go?r=` | Public : le clic d'un spectateur. Redirige (302) vers `ctaUrl`, pré-rempli avec ses coordonnées (`r` : son inscription) : `prefilled_email` et `client_reference_id` pour un lien de paiement Stripe, `name` et `email` pour Calendly et Cal.com, sinon `email`, `first_name`, `last_name`, `name`, `phone`. Jamais un paramètre déjà présent. Offre créée avec `prefill: false` : le lien tel quel. Offre supprimée ou vidée pendant qu'elle était à l'écran : le dernier lien qu'elle montrait. Rien à ouvrir : une page 404 lisible, dans la langue du webinaire |
| POST | `/webinars/:id/quizzes` | `{ question, options:[{label,isCorrect}] }` |
| GET | `/webinars/:id/waiting/quiz` | Public : le quiz de la salle d'attente (`config.waiting.quizId`, un quiz de ce webinaire) → `{ quizId, question, options: [{id, label}] }`, sans la bonne réponse. 404 sans quiz choisi. La réponse passe par `quiz:answer` |
| POST | `/webinars/:id/polls` | `{ question, options:[{label}] }` |
| PUT | `/webinars/:id/statuses` | `{ statuses:[{bucket,label,color,icon}] }` |
| DELETE | `/webinars/:id/offers|quizzes|polls/:xId` | Supprimer |

## Automatisé à heure fixe

`config.evergreen.schedule` : `{ mode: 'now' | 'sessions', days: [1..7] (1 lundi), times: ['HH:MM'] (12 au plus),
justInTime, interval: 15 | 30 | 60, localTime, hideNight, blocked: ['AAAA-MM-JJ'] (200 au plus), count (1 à 10), replay, instant,
cutoff: 0 | 15 | 30 | 60 | 120 | 240 | 1440 }`.
`now` (défaut) : la vidéo démarre dès l'inscription. `sessions` : l'inscrit choisit une séance parmi
les `count` prochaines, cherchées sur deux mois :

- les jours et heures choisis, dans le fuseau du webinaire, ou à l'heure locale du visiteur (`localTime`) ;
- `justInTime` : une séance au prochain quart d'heure (ou demi-heure, heure pile), 5 minutes au moins après l'inscription ;
- `hideNight` : aucune séance entre 23 h et 7 h chez le visiteur ; `blocked` : jours sans séance (fériés) ;
- une séance commencée depuis moins de 15 minutes reste acceptée à l'inscription (arrivée en retard) ;
- `cutoff` : inscriptions closes ce nombre de minutes avant une séance des jours et heures choisis (ni proposée
  ni acceptée) ; la séance « dans quelques minutes » n'en tient pas compte ;
- `instant` : « Regarder tout de suite » est aussi proposé (`sessionAt: 'now'` à l'inscription ou au changement de séance) :
  sans séance, la vidéo se regarde dès maintenant.

La page de la vidéo compte à rebours jusqu'à la séance, puis la lit là où elle en est : en retard, on
arrive en cours de route, et personne ne peut avancer. Séance finie : revoir la vidéo (si `replay`)
ou choisir une autre séance. Emails permis : à l'inscription, après l'inscription, avant la séance
(`before_start`), à son début (`live_start`, jusqu'à 30 minutes après) et après sa fin (`after_end`,
durée de la séance : `plannedDurationSec`, sinon une heure), chacun pour la séance de chaque inscrit,
dans son fuseau. Les textes par défaut que l'hôte n'a pas retouchés deviennent ceux d'une séance
(« Votre place est réservée », « C'est parti »). L'événement `registration` des outils branchés
porte `sessionAt`.

## Chat d'un automatisé

`config.evergreen.liveChat` (section La vidéo de l'éditeur) : `off` (défaut, les spectateurs lisent le chat scripté), `team` (le
message de chacun ne va qu'à l'équipe) ou `session` (automatisé à heure fixe : ceux d'une même séance se voient ;
sans séance, à l'équipe). Même `chat:send` que le direct, sur la socket entrée par `presence:join` : le message porte
`sessionAt` (la séance de l'auteur) et `private` quand il ne va qu'à l'équipe ; une question entre dans la file
(`from_replay`, `replay_sec`) et dans le résumé envoyé à l'hôte. L'équipe (hôte après `host:auth`, lien modérateur
après `mod:auth`) écrit `chat:send { body, replyTo? }` : avec `replyTo` (un message de spectateur), la réponse va à
son auteur seul, ou à toute sa séance, et marque sa question répondue (`question:update`) ; sans, à tous ceux qui
regardent. `staff:evergreen-chat { webinarId }` (ack `{ messages, count }`) : les 300 derniers messages de toutes les
séances, avec `sessionAt`, `registrationId`, `replyTo` et `replyToId`. Console : `/w/:id/console` (hôte) ou le lien
modérateur. Une vraie vente (webhook Stripe, JSON, Shopify) s'annonce aussi à ceux qui regardent un automatisé.

## Pages sur le site de l'hôte, API publique

Chaque page d'un webinaire peut vivre sur le site de l'hôte (WordPress, Systeme.io, Webflow,
ClickFunnels…), sans réglage DNS : dans l'éditeur, Inscription › Page (et dans le bloc « … sur votre site » de l'étape de chaque page), ou `PATCH /webinars/:id/config`.

```json
{ "config": {
    "pages": {
      "register": { "mode": "external", "url": "https://mon-site.ch/webinaire" },
      "thanks":   { "mode": "external", "url": "https://mon-site.ch/merci?source=webinaire" },
      "waiting":  { "mode": "hosted" }
    },
    "notify": { "registrations": true }
} }
```

- Pages : `register` (inscription), `thanks` (remerciement), `waiting` (salle d'attente), `live`
  (direct, ou la vidéo d'un automatisé), `replay`. Un automatisé n'a que `register`, `thanks` et
  `live` : sa salle d'attente et son replay mènent à sa vidéo.
- `hosted` (par défaut, une page absente l'est) : notre page. `custom` (inscription, salle
  d'attente et remerciement seulement) : la page HTML de l'hôte, hébergée par nous, voir plus bas ; gardé
  seulement si la page a été importée. `external` : une adresse `https://`
  complète (sans schéma, `https://` est ajouté), paramètres et ancre gardés, enregistrée sous sa
  forme encodée (`œ` devient `%C5%93`, un domaine accentué passe en punycode). `400` pour
  `http:`, `javascript:`, une espace, un retour à la ligne, des identifiants (`user:pass@`), une
  adresse commençant par `/`, une adresse de la plateforme, gowebi.ch ou live.webinaire.ch (notre page s'y renverrait sans
  fin). `pages: null` revient à nos pages ; le bloc est remplacé en entier.
- Tous les liens suivent ce choix, avec le lien personnel `?r=<registrationId>` ajouté sans toucher
  aux paramètres de l'hôte : `{{lien}}` et `{{lien_replay}}` des emails, boutons, invitation `.ics`
  et lien Google Agenda (le direct), `joinUrl` des intégrations et de l'API, liens « Aperçu » de
  l'éditeur, du tableau de bord et des résultats, passages d'une page à l'autre.
- Compte activé seulement (plan payant), comme les emails : avant, les réglages sont gardés mais
  nos pages restent utilisées partout (un compte ouvert sans vérification ne peut pas faire de
  la plateforme une redirection vers l'adresse de son choix). `GET /webinars/:id` public ne
  donne alors pas `config.pages`.
- Après l'inscription : la page du moment (salle d'attente, direct, replay, vidéo). Seule
  exception, un direct pas encore commencé dont l'hôte a une page `thanks` : elle confirme
  l'inscription. Notre page de remerciement (intégrée ou non) affiche avant le direct
  « Inscription confirmée », la date, l'agenda et le lien vers la salle d'attente ; après, le
  remerciement et l'offre.
- `notify.registrations` : un email à l'adresse de réponse de l'organisation (étape Emails et SMS, sinon
  le propriétaire) à chaque inscription. La première part tout de suite ; les suivantes, regroupées,
  au plus un email toutes les 5 minutes. Compte activé seulement (plan payant).

### Pages HTML hébergées par webinaire.ch

L'hôte (ou son agent) écrit sa page d'inscription, sa salle d'attente ou sa page de remerciement en
HTML ; webinaire.ch la sert à `https://gowebi.ch/p/<webinarId>/register` (ou `/waiting`,
`/thanks`), sans site ni DNS. Tous les liens (emails, agenda, intégrations, lien court) la suivent,
comme une page sur le site de l'hôte.

| Méthode | Route | Rôle |
|---|---|---|
| PUT | `/webinars/:id/pages/:page` | Équipe : `{ html }` (400 000 caractères au plus), `page` : `register`, `waiting` ou `thanks`. Enregistre et choisit la page → `{ page, html, updatedAt, url, live }` (`live` : en ligne, sinon compte pas encore activé). 30 par 10 minutes |
| GET | `/webinars/:id/pages/:page` | Équipe : la page enregistrée (404 sinon) |
| DELETE | `/webinars/:id/pages/:page` | Équipe : retire la page, notre page reprend sa place |
| GET | `/p/:id/:page` | Public, relayé par gowebi.ch : la page, variables remplacées. Pas en ligne (compte pas activé, page retirée) : `302` vers notre page, provenance gardée |

- **Formulaire** : il envoie à `{{inscription}}` (le formulaire HTML sans JavaScript décrit plus
  bas : `first_name`, `email`, les clés des champs, le champ piège `website`), ou appelle l'API
  publique en JavaScript. Après l'inscription, la page du moment, ou `thanks` si elle est choisie.
- **Variables**, remplacées à chaque visite et échappées : `{{id}}`, `{{inscription}}`,
  `{{titre}}`, `{{description}}`, `{{date}}` (avec l'heure et le fuseau), `{{heure}}`, `{{debut}}`
  (le début du direct en ISO 8601, `2026-11-12T18:00:00.000Z`, pour un compte à rebours à soi),
  `{{presentateur}}`, `{{organisateur}}`, et avec le lien personnel `?r=` (après l'inscription, dans
  les emails) : `{{prenom}}`, `{{lien}}` (la page du moment : salle d'attente, direct, replay),
  `{{lien_direct}}` (la salle du direct), `{{lien_replay}}`. Les autres `{{…}}` restent tels quels.
- **Salle d'attente** (`waiting`) : webinaire.ch ajoute un script qui écrit le compte à rebours
  (`2 jours 03:12:45`, puis `03:12:45`) dans tout élément `data-webinaire="compte-a-rebours"`, et
  fait passer la page toute seule au direct quand l'hôte le lance (état relu toutes les 10 s par
  `GET /api/public/webinars/:id`), au replay une fois le direct fini, avec le lien personnel. Une
  visite après le lancement ou après la fin reçoit un `302` vers le direct ou le replay.
- **Sécurité** : la page est servie dans un bac à sable (`Content-Security-Policy: sandbox`, sans
  `allow-same-origin`) : ses scripts tournent, son formulaire envoie, ses liens s'ouvrent, mais elle
  n'a ni cookies ni stockage, et ne lit rien de la plateforme (sessions de l'équipe). Un pixel
  publicitaire y fonctionne moins bien : pour un suivi complet, mettre la page sur son propre site.
  Le lien personnel `?r=` est retiré de la barre d'adresse dès l'ouverture. Compte activé seulement.
- **Images** : dans des fichiers à part (`POST /webinars/:id/media?kind=image&name=`), appelés par
  leur adresse complète : une adresse relative ne mène à rien.

### Intégration (embed.js)

```html
<div data-webinaire="WEBINAR_ID" data-page="register"></div>
<script src="https://gowebi.ch/embed.js" async></script>
```

- `data-page` : `register`, `thanks`, `waiting`, `live` ou `replay`. Options : `data-background`
  (`transparent` ou `brand`), `data-meta-pixel="auto"` (appelle `fbq('track', 'CompleteRegistration')`
  du pixel de la page, avec l'identifiant d'événement calculé par le serveur, jamais celui de
  l'inscription), `data-title` (titre du cadre).
- Le script crée un cadre vers notre page (`?embed=1`), lui transmet `r`, `utm_*` et `fbclid` de
  l'adresse de la page, ajuste sa hauteur, et change de page à sa demande : un cadre d'un autre site
  ne peut pas le faire lui-même. Il ne va que vers les pages réglées pour ce webinaire (lues par
  `GET /public/webinars/:id`) ou vers les nôtres, jamais vers une adresse reçue dans un message.
  Messages vérifiés dans les deux sens : origine, cadre émetteur, types connus.
- Lien personnel : `r` permet de rejoindre le webinaire à la place de l'inscrit. Le script le retire
  de l'adresse de la page dès son chargement (`history.replaceState`, seulement un identifiant à
  nous : un `?r=` de l'hôte reste) et le garde dans le stockage de session de l'onglet, pour le
  cadre et un rechargement. D'une page de l'hôte à une autre du même site, il passe par ce
  stockage, jamais par l'adresse ; vers un autre site, par l'adresse. Un pixel chargé avant le
  script lit encore l'adresse d'arrivée (lien d'un email, formulaire HTML, `redirectUrl`) : placer
  aussi `<script src="https://gowebi.ch/embed.js"></script>` dans le `<head>`, avant le
  pixel, ou exclure `r` des adresses dans le gestionnaire de balises.
- Événement sur la page : `webinaire:registered`, `detail = { registrationId, webinarId, eventId }`.
  ```js
  window.addEventListener('webinaire:registered', (e) => console.log(e.detail.registrationId));
  ```
- Dans le cadre : ni habillage, ni pixel ni bandeau de cookies de la plateforme (ceux de la page de
  l'hôte s'appliquent), et tout marche sans cookie ni stockage (Safari) grâce à `?r=`.
- Les pages spectateur (`/w/:id/register|thanks|waiting|live|evergreen`) sont intégrables par tout
  site (`frame-ancestors *`) ; toutes les autres (régie, éditeur, résultats, connexion, compte, mots
  de passe, un clic, désinscription) ne s'affichent que sur la plateforme (gowebi.ch).

### API publique

CORS ouvert à toute origine, sans identifiants (`Access-Control-Allow-Origin: *`, jamais
`Allow-Credentials`), sur ces routes seulement. Toutes les autres gardent le CORS restreint à
`CORS_ORIGIN` et aux domaines de la plateforme. Par gowebi.ch (adresse stable, relayée à l'API ; live.webinaire.ch marche aussi) :

| Méthode | Route | Rôle |
|---|---|---|
| GET | `/api/public/webinars/:id` | Titre, date, statut, organisateur, prochaines séances d'un automatisé à heure fixe (`sessions`), champs du formulaire (`registration.fields` : `name`, `label`, `required`, `type` parmi `text`, `email`, `tel`, `select`, `radio`, `checkbox`, et `options` pour une liste ou un choix unique ; une case cochée vaut `oui`), adresse de chaque page (`pages.<page>.url`, `hosted`), adresses des points d'entrée. Rien de privé (ni config, ni offres, ni pixel). Cache 30 s |
| POST | `/api/public/webinars/:id/register` | JSON `{ email, firstName?, lastName?, phone?, country?, custom?: { <clé du champ>: réponse }, utm?: { source, medium, campaign, content, term } }` (ou `first_name`, `utm_source`…, `fbclid`), `ads?` comme ci-dessus si le formulaire de l'hôte a recueilli l'accord. Automatisé à heure fixe : `sessionAt?` (une des `sessions` de `GET`, sinon la prochaine) et `tz?`. `201 { registrationId, firstName, eventId, redirectUrl, joinUrl }` |
| POST | `/api/public/webinars/:id/registrations/:registrationId/ready` | Inscription terminée sur la page de l'hôte (groupe rejoint, direct ajouté à l'agenda) : avec `registration.confirmationDelayMinutes`, la confirmation part maintenant au lieu d'attendre le délai. Sans corps → `200 { ok: true }`. Une seule confirmation en tout : un deuxième appel, ou le délai qui passe ensuite, ne la renvoient pas. `404` pour une inscription inconnue ou d'un autre webinaire |
| POST | `/api/register/:id` | Formulaire HTML sans JavaScript (`application/x-www-form-urlencoded`) : `first_name` ou `prenom`, `last_name` ou `nom`, `email`, `phone`, les clés des champs personnalisés, `utm_*`, `fbclid`, et le champ piège `website` (à laisser vide et caché). `303` vers la page d'après l'inscription (voir plus haut) avec `?r=` ; erreur : une page en français avec un lien vers le formulaire. Aucune adresse de redirection n'est lue dans la requête |

Numéro et adresse contrôlés à chaque inscription (nos pages, ces routes, l'API à clé), avec les
règles des pages d'inscription du site webinaire.ch. Avec un pays déclaré (`country`, code ISO) ou un
indicatif tapé (`+41…`, `0041…`), un numéro qui n'existe pas dans ce pays, incomplet, trop long, de
service (gratuit, surtaxé, messagerie) ou inventé (`06 12 34 56 78`, le numéro d'exemple du pays) est
refusé ; un fixe passe, rangé au format international. Sans pays ni indicatif, un numéro de 6 à 15
chiffres passe tel quel. L'adresse est refusée si son domaine ne reçoit pas d'emails (aucun MX au DNS,
ou un MX nul), si elle est jetable (yopmail…) ou d'exemple (`example.com`) ; DNS lent ou en panne :
elle passe. Une faute de frappe vers un domaine qui reçoit passe aussi : c'est à la page de demander
« Vouliez-vous dire … ? ». Refus : `400 { statusCode, message, error, field }`, `field` valant
`phone` ou `email`, le message à montrer sous ce champ ; le formulaire HTML répond une page qui dit
quoi corriger. Une invitation (adresse prouvée) et un paiement terminé (contrôlé avant de payer) ne
sont jamais refusés pour eux.

Même validation et même limite pour toutes les façons de s'inscrire : 120 inscriptions par minute
et par visiteur, toutes routes confondues (`429`). Relayé par Vercel, le visiteur est reconnu grâce
à `RELAY_SECRET` (même valeur sur Vercel et sur l'API, voir `frontend/middleware.ts`) ; sans lui,
toutes ces requêtes comptent pour les adresses de Vercel. Le relais ne transmet que l'adresse vue
par Vercel (`req.ip`), jamais `X-Forwarded-For` : un front servi ailleurs (`next start`, Docker) ne
transmet rien, et ses visiteurs comptent pour son adresse. Rien ne s'inscrit par un `GET`.

```js
// Lire le webinaire et ses champs
const w = await fetch('https://gowebi.ch/api/public/webinars/WEBINAR_ID').then((r) => r.json());

// Inscrire, puis envoyer l'inscrit sur la bonne page (remerciement, salle d'attente…)
const r = await fetch('https://gowebi.ch/api/public/webinars/WEBINAR_ID/register', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ email: 'lea@exemple.ch', firstName: 'Léa', utm: { source: 'site' } }),
}).then((res) => res.json());
window.location.href = r.redirectUrl;
```

```html
<form method="post" action="https://gowebi.ch/api/register/WEBINAR_ID">
  <input name="first_name" placeholder="Prénom" required>
  <input name="email" type="email" placeholder="Email" required>
  <input name="website" tabindex="-1" autocomplete="off" aria-hidden="true" style="position:absolute;left:-9999px">
  <button type="submit">Réserver ma place</button>
</form>
```

### Inscription en un clic

`https://gowebi.ch/w/WEBINAR_ID/1clic?email=EMAIL&prenom=PRENOM` (champs de fusion de
l'outil d'emailing de l'hôte). La page demande confirmation : rien n'est inscrit à l'ouverture (les
antivirus des messageries ouvrent les liens), le bouton inscrit puis mène à la page d'après
l'inscription avec `?r=`. L'email et le prénom sont retirés de la barre d'adresse dès la lecture.

## Emails d'un webinaire, expéditeur, désinscription

| Méthode | Route | Rôle |
|---|---|---|
| GET | `/webinars/:id/emails` | `{ emails, active, sending, limited, orgId, sms, askPhone }` : la séquence, emails et SMS (un automatisé ne voit que l'inscription) ; `active` : compte activé, sans lui aucun email ne part ; `sending` : un service d'envoi est branché ; `limited` : Amazon SES encore en bac à sable, seules les adresses vérifiées reçoivent quelque chose (la double confirmation n'est alors pas demandée, les invitations attendent) ; `sms` : le Twilio branché, `{ from, trial, error, moments }` (`moments` : les SMS que sa carte envoie déjà, parmi `registration`, `starting_soon`, `attended`, `missed`), ou `null` ; `askPhone` : le formulaire d'inscription demande le téléphone (sans lui, aucun SMS ne part) |
| POST | `/webinars/:id/emails` | `{ channel?, trigger, offsetMinutes?, audience?, audienceMinutes?, excludeBuyers?, subject, format?, body, buttonLabel?, buttonUrl?, enabled? }` (20 emails au plus). `channel: 'sms'` : un SMS, `body` seul, 10 au plus (voir [SMS](#emails-moments-destinataires-variables)). `trigger` : `registration`, `after_registration`, `before_start`, `live_start`, `after_start` (N après le début, jamais avant la fin du direct), `after_end`, `replay_ready`, `replay_expiring` (N avant la fin du replay de chaque inscrit, 15 minutes à 30 jours, jamais dans ses 5 dernières minutes). `audience` (après l'inscription, le début, la fin ou le replay) : `all`, `attended`, `absent`, `attended_live`, `attended_replay`, `missed_live`, `bought`, `left_early` et `stayed` (avec `audienceMinutes`, 1 à 600), `left_before_offer`, `saw_offer`, `clicked_offer`. `excludeBuyers` retire ceux qui ont acheté. `buttonUrl` : le lien du bouton (sinon le lien personnel de l'inscrit), variables permises. Variables : `{{prenom}}`, `{{nom}}`, `{{email}}`, `{{titre}}`, `{{description}}`, `{{presentateur}}`, `{{date}}`, `{{heure}}`, `{{delai}}`, `{{lien}}`, `{{lien_replay}}`, `{{organisateur}}`, `{{fin_replay}}` (date de fin du replay de l'inscrit), `{{delai_replay}}` (temps qu'il lui reste), et celles du webinaire (`config.emailVars`, par exemple `{{lien_offre}}`) |
| PATCH | `/webinars/:id/emails/:emailId` | Mêmes champs, seuls ceux envoyés changent |
| DELETE | `/webinars/:id/emails/:emailId` | Supprimer |
| PUT | `/webinars/:id/emails/order` | `{ ids: [...] }` |
| POST | `/webinars/:id/emails/preview` | Rendu d'exemple d'une saisie, avec un inscrit fictif. Corps : les champs d'un email (`trigger`, `subject`, `format`, `body`, `buttonLabel`, `buttonUrl`…), plus `emailId` pour un email enregistré (la confirmation porte l'agenda), et `trigger`, `offsetMinutes` pour le moment de l'exemple. Réponse : `{ subject, html, from, replyTo }` |
| POST | `/webinars/:id/emails/:emailId/test` | Envoie l'email à l'hôte connecté (10 par 15 min), ou `{ to }` à une autre adresse (5 adresses par jour, jamais à une adresse désinscrite des emails de l'organisation). Un SMS : `{ to }` obligatoire, un numéro au format international (`+41 79 123 45 67`), 5 essais par 15 minutes, par le Twilio branché (400 sans lui, 502 avec le refus de Twilio), pas avec une clé d'API. Réponse : `{ ok, to }` |
| POST | `/webinars/:id/emails/test` | Le même test pour un email pas encore enregistré : `{ subject, format?, body, buttonLabel?, buttonUrl?, to? }` |
| GET, PUT | `/webinars/:id/sequence` | La séquence en arbre, voir [Séquence](#séquence-larbre-des-emails-et-sms) |
| GET | `/e/o/:run/:step/:sig.gif` | Public, relayé par `gowebi.ch/api/e/…`. Pixel d'ouverture d'un email de la séquence (ou d'un ancien scénario) : note la première ouverture, rend toujours un GIF de 1 pixel |
| GET | `/e/c/:run/:step/:sig?u=` | Public, relayé de même. Clic sur un lien d'un email de la séquence (ou d'un ancien scénario) : signature vérifiée sur l'envoi, l'étape et l'adresse (404 sinon, jamais de redirection vers une autre adresse), clic noté (et ouverture), puis redirection 302. Les antivirus des messageries et les requêtes HEAD ne comptent pas |
| GET, PATCH | `/orgs/:orgId/email-settings` | `{ fromName, replyTo, fromLocal }` ; lecture pour les hôtes, modification owner/admin |
| POST, DELETE | `/orgs/:orgId/email-domain` | `{ domain }` : domaine propre chez Amazon SES (503 sans SES, 403 compte gratuit ; une déclaration jamais vérifiée ne bloque les autres que 3 jours) |
| POST | `/orgs/:orgId/email-domain/verify` | Relit l'état de vérification chez SES |
| GET | `/unsubscribe?t=` | Public : renvoie vers la page `/desinscription` (ne désinscrit pas) |
| GET | `/unsubscribe/info?t=` | Public : `{ organization, webinar, email (masqué), unsubscribed }` |
| POST | `/unsubscribe?t=` | Public : désinscription (bouton de la page, ou un clic RFC 8058) |
| POST | `/unsubscribe/resubscribe?t=` | Public : « Me réabonner », seule façon de lever une désinscription |
| GET | `/webinars/:id/invitations` | Équipe : `{ subscribers, ready, queued, sent, blocked }` : abonnés aux prochains webinaires (adresse confirmée, pas désinscrits), ceux à inviter (pas encore inscrits ni invités), en cours d'envoi, envoyées, et la raison si rien ne peut partir |
| POST | `/webinars/:id/invitations` | Équipe : invite chaque abonné pas encore inscrit à ce webinaire, une fois (`{ invited, … }`). Les emails partent avec le passage de chaque minute ; une invitation qui n'a plus lieu d'être (désinscrit, inscrit entre-temps, webinaire terminé ou supprimé, plus de 3 jours) se ferme sans email. 400 si le compte n'est pas activé, si les emails partent de l'outil de l'hôte, si le direct est terminé ou sans service d'envoi |

Format `simple` : `# titre`, `- liste`, `1. liste`, `**gras**`, `*italique*`, `[texte](lien)`, `![description](image https)`
seule sur sa ligne. `format` : `simple`, `html`.

## Événements, scoring, analyses

| Méthode | Route | Rôle |
|---|---|---|
| POST | `/events` | Ingestion générique (`offer_view/click`, `conversion`, `reaction`, `exit`…) |
| POST | `/webinars/:id/freeze` | Fige les scores |
| GET | `/webinars/:id/scores` | Leads scorés & classés (`frozen`/`live`). Chaque ligne : `id` (l'inscription, pour son parcours), `link` (son lien personnel court), coordonnées, réponses (`custom_fields`), `watch_seconds`, `replay_seconds`, `viewed_offer`, `clicked_offer`, `converted`, `status` (`Chaud`, `Tiède`, `Froid`, `Replay`, `Absent`), `crm` (les valeurs des propriétés du CRM, par clé) |
| GET | `/webinars/:id/sales` | Équipe : les ventes du webinaire (Stripe, lien de ventes, API), les plus récentes d'abord, remboursées comprises (500 au plus) → `{ sales: [{ orderId, email, registrationId, firstName, lastName, amountCents, refundedCents, currency, status, channel, product, offer, occurredAt }] }` ; `registrationId` : l'inscription au même email (sinon `null`) ; `status` : `paid` ou `refunded` ; `channel` : `stripe` ou `api` |
| GET | `/webinars/:id/registrations/:registrationId/journey` | Équipe : le parcours d'un inscrit, voir [Contacts et parcours](#contacts-et-parcours-le-crm) |
| DELETE | `/webinars/:id/registrations/:registrationId` | Équipe de l'organisation : supprimer un inscrit, en deux appels (le premier ne supprime rien et donne un code), voir [Contacts et parcours](#contacts-et-parcours-le-crm) |
| GET | `/webinars/:id/scores.csv` | Export CSV (colonne `seance` après `inscrit_le` pour un automatisé à heure fixe ; les propriétés du CRM en dernières colonnes, oui/non en clair) |
| GET | `/webinars/:id/analytics` | KPIs + entonnoir de conversion, `offers` (par offre : `views`, `clicks`, `sales`, `revenueCents`, `currency`, plus une ligne « Sans offre rattachée »), `questions` (`authorName`, `body`, `votes`, `answered`, et `fromReplay`, `replaySec` pour celles du replay, après celles du direct), `resources` (documents partagés : `url`, `label`, `clicks`, `people`), `sessions` (automatisé à heure fixe : `at`, `registered`, `attended`, les 30 séances les plus récentes, à venir comprises), `audience` (direct lancé : `{ startedAt, stepMinutes, points, peak: {count, minute}, offers: [{title, minute, viewers}] }`, les présents minute par minute, une présence s'arrêtant au dernier signe de vie de sa page ; `null` pour un automatisé ou un direct pas encore lancé) |
| GET | `/webinars/:id/ai` | Équipe : rédaction et bilan par Claude disponibles ou non (clé `ANTHROPIC_API_KEY` sur le serveur), quota du mois → `{ available, limit, used, remaining }` |
| POST | `/webinars/:id/ai` | Équipe : un texte rédigé par Claude, `{ kind: 'agenda' \| 'description' \| 'email' \| 'offer', instructions?, current?, context? }` |
| GET | `/webinars/:id/ai/report` | Équipe : le dernier bilan du direct par Claude → `{ report: { summary, questions, objections, actions, createdAt } \| null }` |
| POST | `/webinars/:id/ai/report` | Équipe : `{ lang? }`, un nouveau bilan (compte dans le quota). Claude lit les chiffres, les offres, les questions et 400 messages des spectateurs au plus, sans prénoms, emails, numéros ni liens. 400 tant qu'il y a moins de 5 messages et questions |
| GET | `/webinars/:id/chat.csv` | Export du chat : minute du direct, heure, auteur, email, équipe, question, soutiens, répondue, message |
| POST | `/webinars/:id/replay/download` | Fichier MP4 du replay, préparé par Cloudflare à la première demande : `{ parts: [{ partId, status: ready\|inprogress\|error, url, percent }] }`, à redemander tant que `inprogress` |
| GET | `/webinars/:id/replay/backup` | Équipe du webinaire. Le replay est enregistré par le serveur (il se branche sur le direct comme un spectateur, un fichier toutes les 10 minutes envoyé à Cloudflare pendant le direct ; `SERVER_RECORDING=off` le coupe, et le serveur cesse d'enregistrer jusqu'à la fin du direct s'il prend du retard : le direct passe avant) ; la copie du navigateur de la régie reste en secours. Pendant le direct : `{ needed: false, server: 'recording' \| 'connecting' \| 'off' }`. Après : `needed` dit si la régie doit envoyer sa copie (`server: 'none'`, `'error'` ou `'partial'` : il manque un morceau ou plus d'un dixième du direct), ou l'oublier (`'ready'`) ou attendre (`'sending'`). Le replay prend les segments du serveur quand ils couvrent le direct, sinon ceux du navigateur ; l'envoi d'une copie inutile (`POST .../parts/:partId/upload`) répond `409` |

## Contacts et parcours (le CRM)

Un contact est une adresse email : toutes ses inscriptions aux webinaires de l'organisation (sans
les invités entrés au prénom). Il se désigne par l'identifiant de l'une de ses inscriptions, jamais
par son adresse. Équipe de l'organisation seulement (une clé d'API y a droit) ; une personne invitée
sur un seul webinaire lit les parcours de ce webinaire, pas les contacts.

| Méthode | Route | Rôle |
|---|---|---|
| GET | `/orgs/:orgId/contacts?q=&segment=&limit=&offset=` | `{ total, contacts: [{ id, email, firstName, lastName, phone, firstAt, lastAt, webinars, attended, watchSeconds, clicked, purchases, revenueCents, currency, source, subscriber, unsubscribed }] }`, la plus récente activité d'abord. `q` : nom, email ou téléphone ; `segment` : `all`, `attended` (venu au moins une fois, direct ou replay), `absent`, `clicked` (une offre), `buyers`, `subscribers` (abonnés aux prochains webinaires, pas désinscrits) ; `limit` 1 à 200 (50 par défaut). `currency` vide si les achats sont en plusieurs devises |
| GET | `/orgs/:orgId/contacts.csv?q=&segment=` | La même liste en CSV (UTF-8 avec BOM, colonnes dans la langue de `accept-language`) |
| GET | `/orgs/:orgId/contacts/:registrationId` | `{ contact: { …, country, webinars, attended, watchSeconds, purchases, revenueCents, currency }, webinars: [{ webinar: { id, title, type, status, scheduledAt, startedAt }, person, summary, timeline }] }` : chacun de ses webinaires (100 au plus), le plus récent d'abord, avec son parcours |
| GET | `/webinars/:id/registrations/:registrationId/journey` | Équipe du webinaire : `{ person, summary, timeline }` |
| DELETE | `/webinars/:id/registrations/:registrationId?confirm=` | Équipe de l'organisation (une clé d'API y a droit, une personne invitée sur ce seul webinaire non) : supprime une inscription pour de bon, en deux appels. Sans `confirm`, ou avec un code périmé, rien ne part : `428 { message, confirm, expiresAt, registration: { id, email, firstName, lastName, createdAt }, removes: { otherDates, visits, emails, subscriber } }`. Le même appel avec `?confirm=<code>` dans les 10 minutes (le code vaut pour cet inscrit et pour qui l'a demandé) → `{ ok, deleted, removed }`. Partent avec elle : ses inscriptions aux autres dates d'une série, ses présences, ses réponses, ses scores, l'historique de ses emails, ce qui lui était encore prévu (relances, messages de la closeuse), son abonnement aux prochains webinaires. Restent : ses messages du chat (sans lien vers elle), les statistiques (anonymes), les ventes du lien de ventes ou de l'API, sa désinscription des emails. `409` pendant le direct (de cette date ou d'une autre de ses dates), et quand elle a payé (sa place ou une offre) ou qu'un paiement est en cours. Ses fenêtres encore ouvertes se ferment. Les outils branchés gardent ce qu'ils ont reçu |
| GET | `/webinars/:id/crm/properties` | Équipe du webinaire (une clé d'API y a droit) : les propriétés du CRM de ce webinaire, des colonnes à soi → `{ properties: [{ key, label, type, options? }] }`, dans l'ordre de création |
| POST | `/webinars/:id/crm/properties` | `{ label, type, options? }` : une propriété de plus (20 au plus). `label` : 40 caractères au plus, unique sans égard aux majuscules (`409`) ; `type` : `text`, `number`, `select` (liste de choix : `options`, tableau ou texte d'un choix par ligne, 30 au plus), `checkbox` (oui/non) ou `date` → `{ properties }` |
| PATCH | `/webinars/:id/crm/properties/:key` | `{ label?, options? }` : la renomme, ou change les choix d'une liste (le type ne change pas) → `{ properties }` |
| DELETE | `/webinars/:id/crm/properties/:key` | La supprime, avec ses valeurs chez tous les inscrits → `{ properties }` |
| PATCH | `/webinars/:id/registrations/:registrationId/crm` | `{ <key>: valeur \| null, … }` : les valeurs d'un inscrit, ramenées au type de leur propriété (`number` : `"1 490,50"` donne `1490.5` ; `select` : un des choix, sans égard aux majuscules ; `checkbox` : `true`, `false`, `"oui"`, `"non"` ; `date` : `AAAA-MM-JJ`) ; `null` ou un texte vide efface. Clé inconnue ou valeur hors type : `400` → `{ crm }` |

Le parcours d'un inscrit :
- `person` : `id`, `email`, `firstName`, `lastName`, `phone` (au format international quand le pays
  le permet), `country`, `registeredAt`, `sessionAt`, `source` (utm), `answers: [{ key, label, value }]`,
  `link` (lien personnel court), `unsubscribed`, `blocked`.
- `summary` : `status`, `points`, `live: { watchSeconds, percent (part du direct), arrivedAfterMinutes,
  leftAtMinute (null : resté jusqu'à la fin), visits, device }` (null : jamais entré), `replay: { watchSeconds,
  firstAt, lastAt }`, `messages`, `questions`, `reactions`, `pollVotes`, `quizAnswers`, `quizCorrect`,
  `offersSeen`, `offerClicks`, `documents`, `emails: { sent, opened, clicked }` (ouvertures et clics : emails
  suivis de la séquence et des anciens scénarios), `purchases: { count, amountCents, currency }`.
- `timeline` : chaque moment `{ at, kind, … }`, dans l'ordre, avec `minute` (minute du direct) quand il
  a lieu pendant le direct. `kind` : `registered` (`source`, `campaign`), `confirmed`, `paid`, `email_sent`
  (`subject`, `scenario`), `email_opened`, `email_clicked`, `joined` (`replay`, `early` : avant le début,
  salle d'attente comprise), `left` (`replay`, `seconds` : durée de la visite), `message` et `question`
  (`body`, `removed` si masqué par l'équipe, `private`), `reaction` (`emoji`, `count` : celles d'une même
  minute), `poll_vote` (`question`, `answer`), `quiz_answer` (`question`, `answer`, `correct`), `offer_view`
  (la première vue de chaque offre), `offer_click` (`offer`), `resource_click` (`label`, `url`), `exit_popup`,
  `exit_survey` (`reason`), `purchase` (`amountCents`, `refundedCents`, `currency`, `product`, `offer`,
  `refunded`), `unsubscribed`, `blocked`.

Le temps regardé compte les battements des pages (toutes les 5 secondes), et les secondes d'avant
un départ ; la salle d'attente n'en ajoute pas.

## Streaming & intégrations

| Méthode | Route | Rôle |
|---|---|---|
| GET | `/webinars/:id/stream` | `{ whipUrl, playbackUrl, whepUrl?, provider }` (Cloudflare Stream si configuré, sinon OME) |
| POST | `/webinars/:id/stream/rotate` | Bouton panique (équipe du webinaire, compte activé, 10 par heure) : avec Cloudflare, une salle vidéo neuve (nouveau live input, `{ whipUrl, whepUrl, playbackUrl, streamKey }`), la salle entière reçoit `stream:moved` et relit `GET /webinars/:id/stream` ; l'ancien input est effacé 2 minutes plus tard. Avec OME : nouvelle clé de stream |
| POST | `/webinars/:id/integrations` | Ajoute un webhook sortant `{ url, scope?, events? }` (renvoie sa clé de signature `secret`), ou `{ provider: 'klaviyo'\|'whatsapp'\|'meta_capi'\|'mailchimp'\|'activecampaign'\|'kit', config, scope }`. Mailchimp, ActiveCampaign et Kit : la clé est vérifiée chez l'outil (400 si refusée), l'audience est choisie d'office s'il n'y en a qu'une, les champs des rappels sont créés ; un seul branchement par outil et par organisation. `events` : voir `docs/integrations.md`. `scope: 'org'` (tous les webinaires de l'organisation, et tous les outils) : équipe de l'organisation seulement, 403 pour une personne invitée sur ce webinaire |
| GET | `/webinars/:id/integrations` | Liste, sans secret : `config.events`, `config.signed`, `config.lastError` |
| PATCH | `/webinars/:id/integrations/:integrationId` | Comme `test`, `sms-test` et `secret` : sur une intégration de toute l'organisation, équipe de l'organisation seulement (403 sinon). `{ events?, rules?, isActive?, list?, doubleOptIn? }` : événements envoyés, règles « Si… alors… » (webhooks, Klaviyo, Mailchimp, ActiveCampaign, Kit : voir `docs/integrations.md`), pause ; `list` : l'audience, la liste ou le formulaire (`null` : aucun), vérifié dans le compte ; `doubleOptIn` : Mailchimp seulement |
| POST | `/webinars/:id/integrations/:integrationId/test` | Envoi d'essai d'un webhook `{ event? }` → `{ ok, status, error }` |
| GET | `/webinars/:id/integrations/:integrationId/deliveries` | Les 20 derniers envois (événement, réponse, essais, prochain essai) |
| GET | `/webinars/:id/integrations/:integrationId/secret` | Clé de signature du webhook (créée à la première demande pour un webhook d'avant) |
| GET | `/webinars/:id/integrations/:integrationId/lists` | Audiences Mailchimp, listes ActiveCampaign ou formulaires Kit du compte branché `[{ id, name }]`, par nom |
| GET | `/webinars/:id/integrations/:integrationId/klaviyo-lists` | Listes du compte Klaviyo `[{ id, name }]`, par nom, pour les règles « Ajouter à la liste » (502 si la clé n'a pas le droit *Lists*) |
| GET | `/webinars/:id/moderation` | Clé du lien modérateur `{ key }` (`null` : pas de lien). La console est `/w/:id/moderation#<key>` |
| POST | `/webinars/:id/moderation` | Crée le lien, ou le remplace : l'ancien ne marche plus, les consoles ouvertes perdent la main |
| DELETE | `/webinars/:id/moderation` | Désactive le lien |
| GET | `/webinars/:id/host` | Équipe de l'organisation (une personne invitée sur ce seul webinaire reçoit `403`) : le présentateur principal `{ email \| null, candidates: [{ email, name, source: 'org' \| 'webinar' }] }` (l'équipe de l'organisation et les personnes invitées sur ce webinaire, même avant leur compte) |
| PATCH | `/webinars/:id/host` | Équipe de l'organisation : `{ email \| null }`, une adresse de `candidates` (sinon `400`). Lui seul lance, termine et reprend le direct (`POST /start`, `/end`, `/resume` répondent `403` aux autres) ; `null` : toute l'équipe, comme avant. Jamais dans la fiche publique du webinaire |
| GET | `/webinars/:id/presenter-link` | Clé du lien des intervenants `{ key }` (`null` : pas de lien). Les coulisses sont `/w/:id/intervenant#<key>` |
| POST | `/webinars/:id/presenter-link` | Crée le lien, ou le remplace : l'ancien ne marche plus, les intervenants entrés avec lui quittent la scène |
| POST | `/webinars/:id/presenter-link/invite` | Envoie le lien `{ email }` depuis l'adresse de la plateforme (20 par heure) |
| DELETE | `/webinars/:id/presenter-link` | Désactive le lien |
| GET | `/health` | Sonde `{ status, db, redis, stream, email, live, cpu, uptime }` (`live` : un direct est en cours sur la plateforme ; `null` si la base ne répond pas. `cpu` : part de processeur du serveur, 0,1 sur l'offre gratuite de Render, `null` hors conteneur) |

## WebSocket (Socket.IO, même port)

Le client rejoint la room `webinarId`. Événements :

**Participant → serveur** : `presence:join {webinarId, registrationId}` (ack `{attendanceId}` ; `{ full: true }` salle pleine, `{ locked: true }` mot de passe requis, `{ blocked: true }` retiré de la salle),
`presence:heartbeat {deltaSeconds}`, `chat:send {authorName, body, videoTimecodeSec}`,
`reaction {emoji, videoTimecodeSec}`, `quiz:answer {quizId, optionId}` (ack `{isCorrect, optionId, correctOptionId}` : la réponse enregistrée, la première, et la bonne),
`poll:vote {pollId, optionId}` (ack `{ error: 'Sondage clos.' }` une fois le sondage clos), `offer:view {offerId?}`, `offer:click {offerId?}`, `conversion {offerId?}`.

**Animateur → serveur** : `host:join {webinarId}` (ack `{count}`), `host:cue {webinarId, kind:'offer'|'quiz'|'poll'|'resource'|'pin'|'present', data}`.
`resource` : un lien ou un document sous la vidéo de chaque spectateur, `{label, url}` (lien http(s), sinon ack `{error: 'invalide'}` ; un document se téléverse d'abord avec `POST /webinars/:id/media`). `{url, removed: true}` le retire de tous les écrans, des retardataires et du replay au même moment. `staff:state` rend la liste en cours : `resources: [{label, url}]`.
`present` : la présentation, affichée par chaque page comme dans WebinarJam (la vidéo ne porte que la caméra) :
`{mode: 'slides'|'board'|'camera', page?, next?, n?, total?, layout: 'small'|'half'|'big', ink: [{c: '#rrggbb', p: [[x, y], …]}]}` (`camera` : les traits sur l'image de la caméra, sans slide ni tableau).
`page` / `next` : ids d'images de `config.slides.pages` (servies à `/media/:id`) ; ou, à leur place, `deck` (l'id de `config.slides.deck`, servi à `/decks/:id`) à la slide `n`, après `step` révélations (0 à 200 ; une étape de plus s'anime, un retour se fait d'un coup) ; `ink` en proportion de la slide ou du tableau
(0 à 1), `layout` : vous en petit (bulle), moitié-moitié, vous en grand (la présentation en vignette) ; `inset {x, y, w}` (en proportion de l'image, `w` de 0,1 à 1 : toute l'image) : la vignette placée et agrandie à la main par l'animateur (absente : sa place par défaut, en bas à droite ; ignorée en moitié-moitié) ; `split` (moitié-moitié seulement, de 0,2 à 0,8) : la part de la largeur laissée à la caméra, la barre du milieu glissée à la main (absente : la moitié). L'état entier à chaque
changement (relu par les retardataires, au journal du replay) ; `null` arrête. `host:ink {webinarId, id, c, p}` : le trait en
cours, par morceaux, relayé en `ink` aux spectateurs sans être gardé.
`data` à `null` retire (« Fermer ») l'offre, le message épinglé, le sondage ou le quiz de tous les écrans. Sondage ou quiz :
`{pollId | quizId, question, options, showResults?, closed?}` ; il reste à l'écran jusqu'à ce que l'équipe le retire, le vote
fait reste marqué chez le spectateur. Avec `showResults: true` (choisi à la mise en ligne, ou plus tard), le serveur y joint
`results` (les votes relus en base, jamais ceux envoyés), pour la salle, les retardataires et le replay, puis envoie
`poll:results` ou `quiz:results` à toute la salle à chaque vote ; renvoyé avec `showResults: false`, ils redeviennent réservés
à l'équipe. `closed: true` (sondage d'avant, gardé) : plus de vote.
Pour une offre, le serveur pose `shownAt`
et `expiresAt` (si `urgencySec`) à son heure : ce qu'envoie la régie pour ces champs est ignoré. Pour `pin`, `data` est
`{id}` d'un message du chat : l'auteur et le texte sont relus en base.

**Équipe du direct** (l'hôte après `host:auth {webinarId, token}`, ou la console du lien modérateur après
`mod:auth {webinarId, key, name}`, ack `{ok, count}`) : `chat:send` part au nom de l'équipe (`fromHost`, sous le prénom donné à
l'entrée pour un modérateur), `chat:delete {id}`, `host:cue`, `chat:mute {id}` (l'auteur de ce message ne s'affiche plus que chez
lui, et ce message est retiré), `chat:unmute {registrationId}`, `chat:muted-list` (ack `{muted: [{registrationId, firstName}]}`),
`staff:state {webinarId}` (ack `{offer, left, poll, quiz, pin, count}` : ce qui est à l'écran des spectateurs),
`question:list {webinarId}` (ack `{questions: [{id, authorName, body, votes, answered, messageId}]}`, sans réponse et les plus
soutenues d'abord), `question:answered {webinarId, id, answered}`, `staff:participants {webinarId}` (ack `{present, participants:
[{name, present, watchSeconds, points, clicked, bought}]}`, prénom et initiale, ni email ni téléphone),
`staff:block {webinarId, id? | registrationId?, blocked?}` (retire de la salle l'auteur du message `id`, ou l'inscrit : ses
fenêtres reçoivent `viewer:blocked` et se ferment, ses messages sont supprimés, son lien personnel et `presence:join` sont refusés,
même réinscrit avec le même email ; `blocked: false` le rétablit), `staff:blocked-list` (ack `{blocked: [{registrationId,
firstName}]}`). L'équipe reçoit `staff:blocked {registrationId, firstName, blocked}`. Le lien modérateur ne donne jamais
`host:end`. Remplacé ou désactivé, ses consoles reçoivent `mod:revoked` et perdent la main.

**Ventes pendant le direct** : `staff:sales {webinarId}` (ack `{count, totals: [{currency, cents}]}`, ventes payées du
webinaire nettes des remboursements, une ligne par devise, la plus vendue d'abord). À chaque vente, remboursement ou webhook
rejoué, l'équipe reçoit `sales:changed {count, totals, sale?}` ; `sale {firstName, product, amountCents, currency}` n'y est que
pour une vente nouvelle (pas pour une vente trouvée plus de 15 minutes après coup) : c'est l'alerte « Nouvelle vente » de la
régie. La console du lien modérateur, ouverte sans compte, reçoit le nombre et le prénom, jamais les montants (`{count, sale?:
{firstName, product}}`).

**Qui lit les spectateurs** (`config.live.chatMode` dans l'éditeur, puis `chat:mode {webinarId, mode}` depuis la régie, ack
`{ok, mode}`) : `public` (défaut), `private` (un message de spectateur ne va qu'à lui et à l'équipe, `chat:new` porte `private:
true`), `moderated` (pareil, jusqu'à la publication) ou `off` (seule l'équipe écrit ; un spectateur reçoit `{ error: 'Le chat
est fermé pour le moment.' }`). Tous reçoivent `chat:mode {mode}` au changement, et à l'arrivée quand il n'est pas `public` ;
`staff:state` le donne à l'équipe (`chatMode`). `chat:publish {webinarId, id}` (équipe) publie un message privé pour toute la
salle (`chat:new` sans `private`), l'équipe reçoit `chat:published {id}`. `staff:private-chat {webinarId}` (ack `{messages}`) :
les 100 derniers messages privés ou à valider, que l'historique public `/webinars/:id/chat`, le replay et la copie en automatisé
n'ont jamais.

**Mots interdits** (la liste du webinaire, `PATCH /webinars/:id { blockedWords }`, relue toutes les 15 secondes au plus) : le
message d'un spectateur qui en contient un ne va qu'à lui et à l'équipe, quel que soit le mode du chat. `chat:new` porte
`private: true`, et `filtered: true` pour l'équipe seulement (l'auteur ne sait pas pourquoi) ; ce n'est jamais une question.
`staff:private-chat` le liste avec `filtered: true`, et `chat:publish` le publie s'il a été bloqué à tort. Les messages de
l'équipe ne sont jamais filtrés. Même règle dans le chat d'une séance d'automatisé.

**Salle d'attente interactive** (`config.waiting.chat`, `config.waiting.quizId`) : avec `chat: true`, la salle d'attente ouvre
le chat du direct avant l'heure (mêmes règles, même historique `/webinars/:id/chat`, la conversation continue dans la salle du
direct). `quizId` : un quiz de ce webinaire, posé dans la salle d'attente (`GET /webinars/:id/waiting/quiz`), qui compte comme
une réponse du direct (Résultats, scores, statuts du chat). Un quiz supprimé s'en retire.

**Statuts dans le chat** (`config.live.badges {levels, buyers, labels: {buyer, l1, l2, l3}}`, éteints par défaut, libellés de
24 caractères au plus, vides : ceux par défaut) : un message de spectateur porte `badge`, `l1`, `l2` ou `l3` (niveau gagné à 3,
10 puis 25 participations à ce webinaire : messages, réactions comptées 5 au plus, réponses aux sondages et aux quiz), ou
`buyer` (une vente payée à son email, Stripe, Shopify ou webhook). `chat:new`, l'historique `/webinars/:id/chat` et le replay
le portent. Une vente (ou un remboursement sans autre achat) met à jour tous les messages de la personne : la salle et l'équipe
reçoivent `chat:badge {ids, badge}` (`badge` à `null` : retiré). Direct seulement, pas le chat d'un automatisé.

**Questions-réponses** : un message d'inscrit qui contient « ? » est aussi une question (`chat:new` porte alors `questionId` et
`votes`, l'historique `/webinars/:id/chat` aussi, avec `answered`). Un autre inscrit la soutient par `question:vote {id}` (une fois,
jamais la sienne ; ack `{counted, votes}`). La salle reçoit `question:votes {id, votes}` (au plus deux fois par seconde) et
`question:update {id, answered}` ; l'équipe reçoit `question:removed {id}` quand son message est supprimé.

**Fin du direct** : le chat se ferme pour tout le monde, équipe comprise. `chat:send` répond `{ error: 'Le direct est terminé :
le chat est fermé.', closed: true }` ; il rouvre si le direct reprend (`POST /webinars/:id/resume`). Les questions du replay
passent par `POST /webinars/:id/replay/questions`.

**Scène (invités à la caméra, Cloudflare Realtime)** : l'animateur, ses intervenants (lien des intervenants, sans compte) et
les spectateurs qu'il invite se voient et s'entendent ; la régie compose ceux qu'elle met à l'écran dans l'image diffusée (donc
dans le replay) et mêle leur voix au son. Les spectateurs ne reçoivent toujours que le direct. Le secret de l'application
Cloudflare reste sur le serveur, qui fait tous les appels. Sans `CF_REALTIME_APP_ID` et `CF_REALTIME_APP_TOKEN`, `stage:join`
répond `{ error, unconfigured: true }` et la régie l'affiche.
- `stage:join {webinarId, key?, name?, resume?}` : la régie authentifiée (animateur), un intervenant (`key` du lien et `name`
  requis ; lien remplacé ou désactivé : `{ revoked: true }`), ou un spectateur invité depuis moins de deux minutes (son prénom
  d'inscrit). Dix personnes au plus. Ack `{id, role, name, iceServers, participants, resume}`. `resume` : après une coupure (réseau,
  page rechargée), ce jeton rend sa place pendant deux minutes, à l'écran s'il y était, et un spectateur n'a pas besoin d'une
  nouvelle invitation. Il ne sert qu'une fois ; un départ voulu (`stage:leave`) ou un renvoi ne garde pas de place.
- `stage:publish {offer, tracks: [{mid, kind}]}` (ack `{sessionId, answer}`) : envoie caméra et micro (pistes nommées `audio`,
  `video`). `stage:pull {id}` (ack `{sessionId, offer, tracks, v}`) puis `stage:answer {sessionId, answer}` : reçoit un autre
  participant ; seul celui qui a ouvert la session y répond. `stage:media {camOn?, micOn?}`, `stage:leave`. Un intervenant (lien des intervenants) pilote aussi la présentation : `presenter:state {webinarId}` (ack `{slides, present, notes?}` : les slides du webinaire, ce qui est présenté, et les notes d'un deck HTML), puis `host:cue` de type `present` seulement (slides, tableau, retour à la caméra) et `host:ink` (crayon), comme la régie ; tout autre `host:cue` lui répond `forbidden`. Il reçoit le chat de la salle et y écrit au nom de l'équipe, sous son nom : `chat:send {webinarId, authorName, body}` (`fromHost`, sans le mode lent ni le chat fermé des spectateurs).
- Régie seule (l'animateur, ou une console du lien modérateur, qui gère les invités sans monter sur la scène) :
  `stage:watch {webinarId}` (ack `{participants, hands, handsOpen, configured}`), `stage:screen {webinarId, id,
  onScreen}` (cinq invités à l'écran au plus, avec l'animateur), `stage:mute {webinarId, id}` (le participant reçoit
  `stage:muted {locked: true}` : son micro est coupé et verrouillé, `stage:media {micOn: true}` lui répond
  `{ok: false, locked: true}` et sa voix reste hors du direct, même après une coupure) et `stage:unmute {webinarId, id}`
  (le participant reçoit `stage:mic-allowed` et peut le rallumer lui-même), `stage:camera {webinarId, id}` (de même pour la
  caméra : le participant reçoit `stage:camera-off {locked: true}`, `stage:media {camOn: true}` lui répond `{ok: false, camLocked: true}`,
  même après une coupure) et `stage:camera-allow {webinarId, id}` (il reçoit `stage:camera-allowed` et peut la rallumer),
  `stage:remove {webinarId, id}` (le participant reçoit
  `stage:removed {reason: 'host'}`), `stage:hands {webinarId, open}` (la salle reçoit `stage:hands {open}` ; fermées, les demandes
  s'effacent), `stage:invite {webinarId, registrationId, hostName}` (le spectateur reçoit `stage:invited {by, seconds}`),
  `stage:dismiss {webinarId, registrationId}` (le spectateur reçoit `stage:hand {up: false}`).
- Spectateur dans la salle : `stage:status` (ack `{handsOpen, up, invitedSeconds}`, à l'arrivée et au retour),
  `stage:raise {up}` (ack `{ok, up}` ; mains fermées : `{ closed: true }`), `stage:decline`. Sa main tombe avec sa dernière
  fenêtre ; sa page la relève au retour.
- `stage:state {participants: [{id, role, name, onScreen, camOn, micOn, micLocked, camLocked, published, video, audio, v}]}` à chaque changement, pour
  ceux qui sont sur la scène ; la régie reçoit en plus `hands`, `handsOpen` et `configured`. `v` change quand quelqu'un renvoie
  sa caméra : les autres la reprennent. Lien des intervenants remplacé ou désactivé : ils reçoivent `stage:removed {reason: 'link'}`.

**Canal de l'équipe** (l'animateur dans la salle Direct et les consoles du lien modérateur, jamais les spectateurs ni le replay) :
- `team:history {webinarId}` (ack `{messages, big, moderators, talk, voice}`) : le fil (200 messages au plus, gardés le temps du
  direct), le message affiché en grand, les prénoms des consoles connectées, l'état de la voix, et `voice` (Cloudflare Realtime
  configuré). `team:send {webinarId, body, big?, name?}` (500 caractères) : un message de l'équipe ; `big: true` (modérateur
  seulement) l'affiche en grand sur l'écran de l'animateur ; `name` : le prénom de l'animateur. Tous reçoivent `team:message
  {id, from: 'host' | 'mod', name, body, big, at, seen?}` et, pour un message en grand, `team:big {id}`. L'animateur répond « Vu »
  par `team:seen {webinarId, id}` (l'équipe reçoit `team:seen {id}` et `team:big {id: null}`) ; un modérateur le retire par
  `team:clear {webinarId}`. `team:presence {moderators}` à chaque arrivée, départ ou lien remplacé.
- La voix d'un modérateur vers l'animateur, que l'animateur seul entend (jamais mêlée au son diffusé), avec trois verrous : la
  console confirme, l'animateur accepte, et le micro ne passe que pendant que le modérateur maintient son bouton. `talk:ask
  {webinarId}` (modérateur ; ack `{ok, iceServers, talk}` ; un autre modérateur parle déjà : `{error}`), `talk:reply {webinarId,
  accept}` (animateur), `talk:publish {webinarId, offer, tracks: [{mid, kind: 'audio'}]}` (modérateur, une fois accepté ; ack
  `{sessionId, answer}`), `talk:pull {webinarId}` (animateur ; ack `{sessionId, offer, v, iceServers}`) puis `talk:answer
  {webinarId, sessionId, answer}`, `talk:speaking {webinarId, on}` (bouton maintenu ou relâché), `talk:end {webinarId}` (l'un ou
  l'autre). L'équipe reçoit `talk:state {state: 'off' | 'asking' | 'open', name?, published?, v?, speaking?, ended?}` (`ended` :
  `declined`, `host` ou `mod`) ; un modérateur qui part ou dont le lien est remplacé coupe sa voix.

**Serveur → room** : `presence:count {count}`, `chat:new {msg}`, `reaction {…}`,
`poll:results {pollId, results}` et `quiz:results {quizId, results}` (pendant un direct, à l'équipe seulement, à toute la
salle quand l'équipe montre les résultats ; un automatisé les montre à qui a voté), `cue {kind, data}` (dont `pin {id, authorName, body, fromHost}`, rejoué aussi dans le
replay au même moment), `social:proof {action, firstName}`, `chat:muted {registrationId, firstName, muted}` (équipe seulement),
`offer:stock {offerId, left}` (places restantes d'une offre à rareté, à chaque vente ou remboursement).
Une offre minutée arrive avec `remainingSec`, calculé à l'envoi (y compris pour un retardataire) : le client en déduit
l'échéance sur sa propre horloge.

### Ventes par API ou par lien

| Méthode | Route | Rôle |
|---|---|---|
| POST | `/orgs/:orgId/sales` | Équipe de l'organisation (une clé d'API y a droit) : une vente payée ailleurs que sur le Stripe branché. JSON ou formulaire : `{ email, amount (en unités : 1490 pour 1 490 €), currency? (EUR), order_id, product?, offer_id?, webinar_id?, occurred_at? }`, ou `{ event: 'refund', order_id, amount? }` (`amount` : remboursement partiel). Réponse `{ recorded, kind, webinarId?, duplicate?, reason? }`. 400 avec ce qui manque (email, montant, devise de trois lettres, `order_id`), ou un webinaire ou une offre d'une autre organisation |
| POST | `/sales-link/:token` | Public, relayé par `gowebi.ch/api/ventes/:token` : le même contenu, sans clé, ou une commande telle que l'envoient Shopify (paiement de commande), WooCommerce (commande) et PayPal (IPN). Répond 200 à toute commande reçue, comptée ou non (`reason`) ; 404 pour un lien inconnu, remplacé ou retiré |
| GET, POST, DELETE | `/webinars/:id/sales-link` | Administrateurs de l'organisation du webinaire, en personne (`POST` refusé avec une clé d'API) : `{ link, createdAt, lastUsedAt }` ou `{ link: null }` ; `POST` crée ou remplace (l'ancien ne marche plus) ; `DELETE` le retire |

Une vente revient au webinaire nommé, sinon à celui de l'offre nommée, sinon à celui où l'acheteur
(même email) a cliqué une offre dans les 14 jours, sinon à sa dernière inscription des 30 jours ;
sinon `recorded: false`. Une même commande (`order_id`) ne compte qu'une fois dans l'organisation.
Détails et réglages des outils : `docs/integrations.md`, « Lien de ventes ».

### Ventes rattachées à une offre

Une vente (webhook Stripe ou JSON, `POST /sale-webhook?webinar=<ref>`, paiement lu sur un Stripe branché, celui du compte
ou celui d'une section, paiement dans la salle, lien de ventes ou `POST /orgs/:orgId/sales`) est rattachée à une offre : celle que l'émetteur nomme
(`offer_id` dans le JSON, métadonnée `offer_id` de la session Stripe), sinon la dernière offre cliquée par l'acheteur dans ce
webinaire, sinon la dernière offre montrée par la régie. Elle fait baisser les places de cette offre, et s'annonce dans le chat
pendant le direct si l'offre annonce les achats. Un remboursement total rend la place.

### Payer une offre dans la salle

Une offre qui a un prix (`price`) se paie sans quitter le direct quand le Stripe du webinaire a sa clé publique, vérifiée
au branchement. Le Stripe d'un webinaire : le sien s'il en a un, sinon celui de sa section, sinon celui du compte (Paramètres, ou la carte
Stripe de l'étape Offre de l'éditeur). La régie
lance l'offre comme d'habitude : le serveur y ajoute sa mini page (`checkout` : `amountCents`, `currency`, `compareCents`,
`items`, `guarantee`, `klarna`, `publishableKey`, `mode`), lue en base, jamais envoyée par la régie. La salle l'ouvre d'elle-même,
dans sa colonne à gauche de la vidéo (le chat reste à droite), repliable ; sur téléphone, elle monte sous la vidéo. Relancée
par la régie, elle se rouvre chez ceux qui l'avaient repliée. Le paiement est un PaymentIntent créé au clic sur « Payer »
(`POST /webinars/:id/offers/:offerId/pay`), sur le compte de l'hôte : la carte (Apple Pay et Google Pay en sont), et Klarna
(`klarna: true`) quand le compte l'accepte dans la devise de l'offre, en une fois seulement. Klarna s'ouvre dans un nouvel onglet
(`…/pay/:paymentId/redirect`) : le direct continue, la mini page relit le paiement jusqu'à son retour. Navigateur qui bloque l'onglet :
Klarna prend la page, puis ramène à la salle avec `?paiement=<id>&offre=<offerId>`, qui relit le paiement (`…/confirm`) et nettoie l'adresse.

| Méthode | Route | Rôle |
|---|---|---|
| GET | `/orgs/:orgId/stripe` | Équipe de l'organisation : `{ account, sections: [{ name, stripe }], webinars: [{ id, title, section, scheduledAt, stripe }] }` (`webinars` : tous ceux de l'organisation, `stripe` le leur ou `null`), chacun `{ id, mode, key (masquée), publishable, payInRoom, applePay, installments, klarna, error, errorAt }` ou `null` (`klarna` : les devises où le compte accepte Klarna, `null` : pas encore vérifié, `PATCH { recheck: true }`) |
| POST | `/orgs/:orgId/stripe` | Brancher Stripe pour le compte, pour une section (`section`) ou pour un webinaire (`webinarId`, de l'organisation ; pas les deux) : `{ apiKey, publishableKey?, section?, webinarId? }`. La clé secrète (ou restreinte, écriture sur Checkout Sessions et PaymentIntents) est vérifiée chez Stripe ; la clé publique aussi : un PaymentIntent d'essai de 1,00 est créé, relu avec elle (même compte), puis annulé. Le domaine de la salle est déclaré pour Apple Pay et Google Pay quand la clé le permet. Klarna est essayé dans chaque devise (EUR, CHF, GBP, USD : un PaymentIntent de 10,00 jamais confirmé, annulé aussitôt). Un Stripe par compte, par section et par webinaire ; un webinaire prend le sien, sinon celui de sa section, sinon celui du compte |
| PATCH | `/orgs/:orgId/stripe/:integrationId` | Celui du compte, d'une section ou d'un webinaire. Ajouter ou changer la clé publique : `{ publishableKey }`, vérifiée de même ; `{ recheck: true }` revérifie la clé en place (droits ajoutés chez Stripe). La vérification dit aussi si la clé fait les paiements en plusieurs fois (`installments`) |
| DELETE | `/orgs/:orgId/stripe/:integrationId` | Débrancher : ses webinaires prennent le Stripe le plus précis qui reste |
| GET | `/webinars/:id/stripe` | Équipe du webinaire : `{ connected, mode, payInRoom, installments, publishable, section, scope }`, le Stripe qui encaisse ce webinaire ; `scope` : `webinar` (le sien), `section` ou `account` ; `klarna` : ses devises ; `own` : le sien (vue de Paramètres) et `orgTeam`, pour l'équipe de l'organisation |

### Synchro chat ↔ vidéo
Chaque message/événement porte `videoTimecodeSec`. Le client ne l'affiche qu'en
atteignant ce timecode → pas de spoiler malgré la latence de diffusion.

## Variables d'environnement (backend)

`EVERGREEN` (`on` : webinaires automatisés ouverts ; sans elle, fermés en production, ouverts ailleurs),
`DATABASE_URL`, `REDIS_URL` (ou `memory`), `PORT`, `APP_URL`, `PGPOOL_MAX`,
`JWT_SECRET`, `EMAIL_FROM`, `AWS_SES_REGION`/`AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY` (Amazon SES,
prioritaire) ou `SMTP_URL`,
`CF_ACCOUNT_ID`/`CF_STREAM_TOKEN`/`CF_CUSTOMER_SUBDOMAIN` (Cloudflare Stream),
`CF_REALTIME_APP_ID`/`CF_REALTIME_APP_TOKEN` (Cloudflare Realtime, invités à la caméra ; facultatives) et
`CF_TURN_KEY_ID`/`CF_TURN_KEY_TOKEN` (relais TURN de Cloudflare, pour les réseaux qui bloquent le WebRTC direct ; facultatives),
`OME_WHIP_BASE`/`OME_PLAYBACK_BASE` (OvenMediaEngine), `RELAY_SECRET` (au moins 16 caractères,
le même sur Vercel : adresse du visiteur relayée par le front (gowebi.ch, live.webinaire.ch) pour l'API publique et le
formulaire HTML).
