# Référence API : live.webinaire.ch

Base de production : `https://webinaire-api.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://live.webinaire.ch/docs/api.md`.
Un agent la télécharge (`curl -s https://live.webinaire.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 ses emails et ses scénarios, 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.

1. **Créer la clé** : live.webinaire.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, séquence d'emails et scénarios d'emails.
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/scenarios/:sid`,
   `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), `GET /webinars/:id/scores`
   (inscrits et scores), `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. 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.onrender.com/auth/me -H "x-api-key: $WEBINAIRE_API_KEY"
curl -s -X POST https://webinaire-api.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.
- 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? }` |
| `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 |
| `offers` | Les champs de `POST /webinars/:id/offers`, plus `cueAtSec` : seconde où elle apparaît dans un automatisé. Pas de champ prix : il s'écrit dans `title` ou `subtitle` (« Accompagnement 3 mois : 1 490 € »). 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` | La séquence d'emails, 20 au plus : fournie, elle **remplace** celle par défaut (confirmation, rappels, replay). Absente ou vide : la séquence par défaut, dans la langue du webinaire. Champs de `POST /webinars/:id/emails`, voir [Emails](#emails-moments-destinataires-variables) |
| `customPages` | Pages HTML de l'hôte, hébergées par webinaire.ch : `{ register?: '<!doctype html>…', thanks?: '…' }`, voir [Pages HTML hébergées](#pages-html-hébergées-par-webinairech). Chaque page fournie est choisie pour ce webinaire |
| `scenarios` | Scénarios d'emails (arbres avec conditions), 10 au plus : `{ name, steps, enabled?, stopOnPurchase? (défaut true : l'acheteur sort du scénario), trackOpens? (défaut true) }`. `enabled: true` : actif dès la création, chaque email complet exigé. Voir [Scénarios](#scénarios-format-des-étapes) |

**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.

Réponse : `{ webinarId, created, urls: { register, waiting, live, studio, edit }, scenarios: [{ id, name, enabled }], warnings }`.
`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" }] }
  ],
  "emails": [
    { "trigger": "registration", "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" },
    { "trigger": "before_start", "offsetMinutes": 1440, "subject": "Demain : {{titre}}", "body": "Bonjour {{prenom}}, c'est demain à {{heure}}." },
    { "trigger": "before_start", "offsetMinutes": 60, "subject": "Dans 1 heure : {{titre}}", "body": "Bonjour {{prenom}}, on commence dans {{delai}}." },
    { "trigger": "live_start", "subject": "C'est en direct", "body": "Bonjour {{prenom}}, **{{titre}}** vient de commencer.", "buttonLabel": "Rejoindre le direct" },
    { "trigger": "after_end", "offsetMinutes": 60, "audience": "absent", "subject": "Le replay de {{titre}}", "body": "Bonjour {{prenom}}, le replay est disponible.", "buttonLabel": "Voir le replay", "buttonUrl": "{{lien_replay}}" }
  ],
  "scenarios": [
    {
      "name": "Relance de l'offre",
      "enabled": true,
      "steps": [
        { "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}}" },
            { "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}}" }]
            }
          ],
          "no": [
            { "type": "email", "subject": "Vous l'avez manqué ?", "body": "Bonjour {{prenom}}, le replay vous attend.", "buttonLabel": "Voir le replay", "buttonUrl": "{{lien_replay}}" }
          ]
        }
      ]
    }
  ]
}
```

## 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 |
| `after_start` | N minutes après le début | 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`, `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é).

## Scénarios : format des étapes

Un scénario suit chaque inscrit depuis son inscription, étape par étape : `steps` est une liste
d'étapes, et une condition ouvre deux suites, `yes` et `no`. 80 étapes et 6 niveaux de conditions au
plus. `id` (6 à 24 lettres minuscules et chiffres) est facultatif, sauf pour un email visé par une
condition `opened` ou `clicked`.

- **Email** : `{ type: 'email', id?, subject, format?, body, buttonLabel?, buttonUrl? }`, avec les
  variables des emails.
- **Attente d'une durée** : `{ type: 'wait', mode: 'delay', minutes }` (1 à 43 200).
- **Attente d'un moment** : `{ type: 'wait', mode: 'moment', moment, minutes }` : jusqu'à
  `before_start` (N minutes avant le début, 5 à 43 200), `live_start` (0), `after_end` (N après la
  fin), `replay_ready` (N après le replay), `replay_expiring` (N avant la fin du replay, 15 à
  43 200). Un automatisé à la demande n'a pas de moment : seulement des durées.
- **Condition** : `{ type: 'condition', test: { field, … }, yes: [étapes], no: [étapes] }`, toujours
  la **dernière** étape de sa liste (la suite va dans `yes` ou `no`). `field` : `attended` (a
  regardé, direct ou replay), `attended_live`, `attended_replay`, `stayed` (`minutes` : a regardé 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 plus haut dans le scénario).

Créé inactif sauf `enabled: true`. Pour l'activer ensuite : `PATCH /webinars/:id/scenarios/:sid`
avec `{ enabled: true }` (`includeExisting: true` y fait entrer aussi les inscrits déjà là).

## 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/:id` | Détail (inclut `config`, `stream_key`) ; `organizer` : nom montré aux spectateurs (`config.legal.organizer`, sinon celui de l'organisation) |
| PATCH | `/webinars/:id` | Champs cœur (titre, date, durée, type) |
| 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) }` : salle du direct (cliquer pour démarrer, bouton plein écran, liens sous la vidéo). `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, PATCH | `/orgs/:orgId/settings` | `{ retentionMonths, subscribers, blockedWords, 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, mots interdits du chat (`blockedWords` : tableau ou texte, un mot ou une expression par ligne, `*` final pour les variantes, 1 000 au plus ; un message qui en contient un ne va qu'à son auteur et à l'équipe, marqué filtré, jamais une question), et `plan: { id, limits, usage: { team, viewerHours } }` (lecture seule). Lecture pour l'équipe, modification owner/admin |
| 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 (+ rappels « en direct ») ; 409 pour un webinaire automatisé |
| POST | `/webinars/:id/end` | Termine + **fige les scores** + prévient tous les spectateurs (`webinar:ended`) |

## 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é |
| 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 live.webinaire.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 |
| PUT | `/webinars/:id/slides` | Équipe : `{ name, pages: [id, …] }`, les images envoyées dans l'ordre (300 au plus, doublons permis), remplace les slides d'avant → `{ name, pages, updatedAt }`, 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 |
| DELETE | `/webinars/:id/slides` | Équipe : plus de slides sur la plateforme ; leurs images partent au bout d'un mois |
| GET | `/webinars/:id/content` | `{ offers, quizzes, polls, statuses }`. `statuses` : paliers internes du score des inscrits, montrés nulle part (ni aux spectateurs ni dans les exports) |
| POST | `/webinars/:id/offers` | Créer une offre : `{ title, subtitle?, ctaUrl?, ctaLabel?, internalName?, imageUrl?, newTab?, prefill?, urgencySec?, urgencyEnd?, scarcityUnits?, announce? }`. `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 |
| 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` (onglet Vidéo) : `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 : onglet **Pages** de l'éditeur, 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 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 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
  live.webinaire.ch 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 (onglet Emails, 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 ou de remerciement en HTML ; webinaire.ch la sert
à `https://live.webinaire.ch/p/<webinarId>/register` (ou `/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` 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 live.webinaire.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}}`,
  `{{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_replay}}`. Les autres `{{…}}` restent tels quels.
- **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 live.webinaire.ch (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://live.webinaire.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://live.webinaire.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 live.webinaire.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`. Par live.webinaire.ch (adresse stable, relayée à l'API) :

| 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/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 |

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://live.webinaire.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://live.webinaire.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://live.webinaire.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://live.webinaire.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 }` : la séquence (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) |
| POST | `/webinars/:id/emails` | `{ trigger, offsetMinutes?, audience?, audienceMinutes?, excludeBuyers?, subject, format?, body, buttonLabel?, buttonUrl?, enabled? }` (20 au plus). `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 `scenario: true` pour un email de scénario (sans l'agenda de la confirmation). 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) |
| POST | `/webinars/:id/emails/test` | Le même test pour un email écrit ailleurs (étape d'un scénario) : `{ subject, format?, body, buttonLabel?, buttonUrl?, to? }` |
| GET | `/webinars/:id/scenarios` | Scénarios d'emails du webinaire : `[{ id, name, enabled, empty, active, total, updatedAt }]` |
| POST | `/webinars/:id/scenarios` | `{ name, steps? }` (10 par webinaire), créé inactif. Renvoie le scénario complet |
| GET | `/webinars/:id/scenarios/:sid` | `{ id, name, enabled, stopOnPurchase, trackOpens, steps, kind, stats }`. `stats.steps[stepId]` : `here` (en attente à cette étape), `sent`, `skipped`, `failed`, `passed`, `missed`, `yes`, `no`, et pour un email `opened` et `clicked` ; `stats` : `active`, `done`, `stopped`, `outside` (inscrits pas encore dans le scénario) |
| PATCH | `/webinars/:id/scenarios/:sid` | `{ name?, steps?, stopOnPurchase?, trackOpens?, enabled?, includeExisting? }`. `trackOpens` : pixel d'ouverture dans les emails (les clics sont toujours suivis). Activer demande des emails complets ; `includeExisting` fait entrer aussi les inscrits déjà là. Une étape supprimée passe ses inscrits à la suivante ; une attente modifiée compte depuis l'arrivée de chacun |
| DELETE | `/webinars/:id/scenarios/:sid` | Supprime le scénario et les parcours |
| POST | `/webinars/:id/payments/:checkoutId/confirm` | Public, page de retour de Stripe (`/w/:id/paiement?c=`). Relit la session chez Stripe avec la clé de l'hôte : `{ status: 'paid', id, firstName, eventId, link }` (l'inscription faite, une seule fois), `{ status: 'pending' }`, ou `{ status: 'failed', message }`. Une passe toutes les 2 minutes inscrit ceux qui ont payé sans revenir |
| GET | `/e/o/:run/:step/:sig.gif` | Public, relayé par `live.webinaire.ch/api/e/…`. Pixel d'ouverture d'un email de 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 scénario : signature vérifiée sur le parcours, 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`) |
| GET | `/webinars/:id/registrations/:registrationId/journey` | Équipe : le parcours d'un inscrit, 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) |
| 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` |

## 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 }` |

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
  des 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` |
| GET | `/webinars/:id/integrations` | Liste, sans secret : `config.events`, `config.signed`, `config.lastError` |
| PATCH | `/webinars/:id/integrations/:integrationId` | `{ 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/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, uptime }` (`live` : un direct est en cours sur la plateforme ; `null` si la base ne répond pas) |

## 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}`, `offer:view {offerId?}`, `offer:click {offerId?}`, `conversion {offerId?}`.

**Animateur → serveur** : `host:join {webinarId}` (ack `{count}`), `host:cue {webinarId, kind:'offer'|'quiz'|'poll'|'resource'|'pin', data}`.
`data` à `null` retire l'offre ou le message épinglé, ou clôt le sondage ou le quiz. 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.

**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é.

**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`.
- Régie seule : `stage:watch {webinarId}` (ack `{participants, hands, handsOpen, configured}`), `stage:screen {webinarId, id,
  onScreen}` (cinq invités à l'écran au plus, avec l'animateur), `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, 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'}`.

**Serveur → room** : `presence:count {count}`, `chat:new {msg}`, `reaction {…}`,
`poll:results {pollId, results}`, `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 `live.webinaire.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 le compte Stripe branché dans
Intégrations, 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.

### 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)

`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 live.webinaire.ch pour l'API publique et le
formulaire HTML).
