# Le connecteur de publication webhook de Kortex

Version du contrat : **1**. Cette page est publique : elle décrit la forme
des appels, jamais vos données. Elle s'adresse à l'équipe d'un site comme à
l'IA qui code son service.

## En bref

Votre site n'est pas un WordPress que Kortex pilote directement ? Par exemple
un site statique (Astro, Hugo, Eleventy, Jekyll) dont les pages naissent d'un
dépôt Git, ou une boutique équipée de son propre service.

Avec le connecteur **webhook**, votre espace Kortex envoie chaque décision de
l'équipe (approuver, publier, dépublier, renommer, changer l'en-tête) à **une
adresse de votre site**, signée avec un secret que vous seul et Kortex
connaissez. Votre service applique la décision (écrit le fichier, pousse le
commit, reconstruit la page) et répond. Kortex n'a aucun accès à votre dépôt
ni à votre serveur.

- **Ce que fait Kortex** : rédige, fait relire, enregistre les décisions,
  envoie chaque décision une fois, dans l'ordre, signée ; réessaie quand
  votre service est indisponible ; affiche le résultat à l'équipe.
- **Ce que fait votre service** : vérifie la signature, applique, répond. Il
  est seul juge de ce qui entre sur le site : il refuse ce qui ne respecte
  pas vos règles (longueurs, liens, termes), et l'espace montre son message.

## Mettre en service

### Dans votre espace Kortex (administrateur du site)

1. **Réglages › Publication** : connecteur **webhook** ; URL de votre service
   (`https://…`, sans paramètre) ; format du texte (**Markdown** pour un site
   statique, HTML sinon) ; enregistrer.
2. **Secret partagé** : collez le secret (32 à 256 caractères ; recommandé :
   32 octets aléatoires en base64, `openssl rand -base64 32`). Il est chiffré,
   jamais réaffiché, jamais journalisé. Le remplacer coupe l'ancien
   immédiatement.
3. **Tester la connexion** : Kortex envoie l'événement `test` ; « Connexion
   établie » quand votre service répond `202`.

Les décisions prises **avant** l'activation ne sont pas envoyées : passer en
webhook ne rejoue pas l'historique sur le site.

### De votre côté

1. Exposez `POST <votre URL>` en https.
2. Gardez le même secret.
3. Implémentez la vérification (§ Sécurité), les événements (§ Événements) et
   les réponses (§ Réponses). L'exemple en fin de page est un point de départ
   complet.

## L'appel

- `POST` sur l'URL du connecteur.
- `Content-Type: application/json; charset=utf-8`, corps UTF-8 sans BOM,
  **1 048 576 octets au plus**.
- En-têtes :

| En-tête | Valeur |
|---|---|
| `X-Kortex-Timestamp` | `AAAA-MM-JJThh:mm:ssZ`, égal au champ `horodatage` du corps |
| `X-Kortex-Signature` | `sha256=` + HMAC-SHA256 en hexadécimal minuscule (64 caractères) |
| `X-Kortex-Evenement` | l'`id_evenement` de l'appel |
| `User-Agent` | `Kortex-Publication/1` |

- Kortex attend la réponse **30 secondes**, ne suit **jamais** une
  redirection, et envoie **un appel à la fois** par espace, dans l'ordre des
  décisions.

### Le corps

```json
{
  "version_contrat": 1,
  "evenement": "approve",
  "id_evenement": "7c1a1e6e-2b9e-4f8a-9a5c-0d4f1c2b3a44",
  "horodatage": "2026-11-01T10:12:03Z",
  "espace": "1001flows",
  "contenu": {
    "id": "c0b8b8e2-3f1d-4a8e-9d55-1f2a3b4c5d6e",
    "version": 3,
    "type": "article-blog",
    "slug": "heberger-n8n-en-france",
    "ancien_slug": null,
    "cocon": "usage",
    "entete": {
      "titre_h1": "Héberger n8n en France : trois hébergeurs comparés",
      "title_seo": "Héberger n8n en France : OVHcloud, Scaleway, Clever Cloud",
      "meta_description": "OVHcloud, Scaleway et Clever Cloud comparés pour héberger n8n.",
      "image_alt": null,
      "requetes_secondaires": ["n8n OVHcloud", "n8n Scaleway"],
      "categories": ["Hébergement"],
      "etiquettes": ["n8n"]
    },
    "corps": { "format": "markdown", "texte": "## Pourquoi héberger en France\n\n…" },
    "resume": "Une décision par ligne.\nTrois à cinq lignes.",
    "date_publication": "2026-11-05",
    "auteur_decision": "relecteur@exemple.fr",
    "decision_id": "a1b2c3d4-…"
  }
}
```

| Champ | Règle |
|---|---|
| `version_contrat` | `1` |
| `evenement` | `approve`, `publish`, `depublier`, `renommer_adresse`, `entete` ou `test` |
| `id_evenement` | UUID, unique par événement ; **identique d'un réessai à l'autre** |
| `horodatage` | moment de l'envoi, UTC, à la seconde ; **change à chaque essai** |
| `espace` | le nom de l'espace Kortex (`1001flows` pour `1001flows.kortex.center`) |
| `contenu` | absent pour `test` |
| `contenu.id` | identifiant Kortex du contenu (chaîne, UUID) — stable : utilisez-le comme clé |
| `contenu.version` | numéro de version du contenu dans Kortex, ≥ 1. Un `entete` ou un `publish` réutilise la version courante : la règle est **≥ à la dernière acceptée**, jamais strictement croissante |
| `contenu.type` | `article-blog` (v1) |
| `contenu.slug` | l'adresse du contenu ; pour `renommer_adresse`, la **nouvelle** |
| `contenu.ancien_slug` | pour `renommer_adresse` seulement, sinon `null` |
| `contenu.cocon` | le cocon sémantique du brief, ou `null` |
| `contenu.entete.*` | l'en-tête courante ; une valeur absente vaut `null` (tableaux : `[]`) |
| `contenu.corps` | `{format, texte}` pour `approve` et `publish` ; `null` sinon. `format` = `markdown` ou `html` selon le réglage |
| `contenu.resume` | le résumé (texte, lignes séparées par `\n`) ou `null` |
| `contenu.date_publication` | `AAAA-MM-JJ` voulue, ou `null` (publiez alors au jour de l'appel) |
| `contenu.auteur_decision` | e-mail du compte qui a décidé — pour votre message de commit |
| `contenu.decision_id` | identifiant de la décision dans Kortex — pour votre journal |

**Le contrat ne fixe ni longueur ni liste de valeurs** : ce sont vos règles.
Refusez en `400` avec un `message` lisible ; l'équipe le verra tel quel dans
son espace, et votre référentiel Kortex guidera le rédacteur pour la suite.

### Le texte en Markdown

Avec `format: markdown`, Kortex convertit le corps validé : titres `##` à
`####` (jamais `#` : le titre vient de `titre_h1`), paragraphes, gras `**`,
italique `*`, liens `[texte](adresse)`, listes `-` et `1.`, tableaux `|`.
Pas d'image, pas de HTML. Un corps qui contiendrait autre chose n'est **pas
envoyé** : la décision est refusée dans l'espace avec le fragment en cause.

## Événements

| Décision dans l'espace | Événement | Ce que votre site en fait (recommandé) |
|---|---|---|
| Approuver | `approve` | crée ou met à jour le contenu **en brouillon**, non indexé, consultable par l'équipe |
| Publier | `publish` | le rend public (à `date_publication`, ou le jour même) |
| Publier à une date future | `publish`, **envoyé le jour venu** | un site statique ne publie rien seul : Kortex attend la date pour envoyer |
| Dépublier | `depublier` | le retire du public (404) ; il redevient brouillon |
| Renommer l'adresse | `renommer_adresse` | déplace `ancien_slug` vers `slug` et pose une redirection 301 |
| Résumer, classer, décrire l'image, changer la date | `entete` | met à jour l'en-tête seulement ; ne change pas l'état |
| Tester la connexion | `test` | ne modifie rien ; répond `202` |
| Refuser, répondre, faire réécrire | aucun | ces décisions ne touchent pas au site |

## Réponses

Répondez toujours en JSON.

| Code | Corps | Ce que fait Kortex |
|---|---|---|
| `202` | `{"ok":true,"etat":"brouillon","adresse":"https://…/","apercu":"https://…","commit":"7f3a1b2c","delai_estime_s":180}` | **appliqué** : retient l'adresse et le commit, ferme la décision, affiche « Voir le brouillon » ou « Voir la page » |
| `200` | la réponse d'origine | rejeu reconnu (même `id_evenement`) : comme `202` |
| `400` | `{"ok":false,"error":"corps_invalide","message":"…"}` | **refusé**, message affiché, pas de réessai |
| `401` | `{"ok":false,"error":"signature"}` ou `"horodatage"` | refusé : « vérifier le secret du connecteur » |
| `404` | `{"ok":false,"error":"site_inconnu"}` | refusé : « vérifier l'URL » |
| `409` | `{"ok":false,"error":"version_ancienne","version_connue":4}` | refusé : une version plus récente est déjà là |
| `422` | `{"ok":false,"error":"refuse","motifs":["…"]}` | refusé, motifs affichés |
| `503` | `{"ok":false,"error":"depot_indisponible"}` | **réessai** |
| pas de réponse en 30 s, erreur réseau, autre `5xx` | — | **réessai** |

`etat` : `brouillon`, `publie`, `depublie`, `renomme`, `ok` (pour `test`).
`apercu` (facultatif, recommandé) : un lien de brouillon **signé et
temporaire** ; l'équipe l'ouvre sans mot de passe et Kortex n'en garde aucun.
Un `2xx` sans `"ok": true` est lu comme un refus (réponse hors contrat). Une
redirection (`3xx`) est un refus.

### Réessais et ordre

- Réessais : après **1 min, 5 min, 15 min, 1 h, 6 h**, puis l'envoi passe en
  **échec**. Chaque réessai renvoie **le même corps et le même
  `id_evenement`**, avec un nouvel horodatage (donc une nouvelle signature).
- **Un envoi en réessai ou en échec bloque les suivants** de l'espace : un
  `publish` ne part jamais avant son `approve`. Une publication à date
  attend sa date sans bloquer les autres.
- Un envoi en échec se débloque dans **Réglages › Publication** :
  « Réessayer maintenant » ou « Abandonner cet envoi » (la décision est alors
  fermée en échec et la suite repart). Les deux gestes sont journalisés.
- **Pause** : aucun appel ne part ; les décisions s'empilent et partent dans
  l'ordre à la reprise.

## Sécurité

### La signature

1. Lisez le corps **brut** (les octets reçus, avant tout analyse JSON).
2. `message = X-Kortex-Timestamp + "." + corps brut`.
3. `attendue = "sha256=" + hex(HMAC-SHA256(clé = secret en UTF-8, message))`.
4. Comparez à `X-Kortex-Signature` **à temps constant**.

Vecteur de test (à reproduire avant toute mise en service) :

| Entrée | Valeur |
|---|---|
| secret | `secret-de-test` |
| `X-Kortex-Timestamp` | `2026-11-01T10:12:03Z` |
| corps | `{"a":1}` |
| message signé | `2026-11-01T10:12:03Z.{"a":1}` |
| `X-Kortex-Signature` attendue | `sha256=154e7ab2c6edacccaf1ecf200fbbf7dba513d97cac959f4ff5f9519a28a8f0a7` |

### Ce que votre service doit refuser (`401`)

- signature absente, de mauvaise longueur ou différente ;
- horodatage absent, mal formé, ou à **plus de 300 secondes** de votre
  horloge, dans un sens ou dans l'autre.

### Rejeu

Gardez chaque `id_evenement` traité **7 jours au moins** avec sa réponse. Un
second appel avec le même `id_evenement` reçoit `200` et la réponse
d'origine, sans rien refaire.

### Versions

Refusez en `409` une `version` **strictement inférieure** à la dernière
acceptée pour ce `contenu.id`.

### Le reste

- Nettoyez le texte reçu **même s'il est propre** : n'acceptez que ce que
  votre générateur sait afficher.
- Ne journalisez ni le secret, ni la signature.
- Le secret ne transite jamais dans un appel : seule la signature voyage.

## Exemple : un service minimal (Node.js 20+)

À adapter : l'écriture dans votre dépôt et la reconstruction de la page sont
les vôtres.

```js
import { createHmac, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";

const SECRET = process.env.KORTEX_SECRET;          // le même que dans l'espace
const vus = new Map();                             // id_evenement → réponse (7 jours, à persister)
const versions = new Map();                        // contenu.id → dernière version acceptée

function signatureValide(h, brut, sig) {
  if (!/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$/.test(h ?? "")) return false;
  if (Math.abs(Date.now() - Date.parse(h)) > 300_000) return false;
  const attendue = Buffer.from("sha256=" + createHmac("sha256", SECRET).update(`${h}.${brut}`).digest("hex"));
  const recue = Buffer.from(sig ?? "");
  return attendue.length === recue.length && timingSafeEqual(attendue, recue);
}

createServer((req, res) => {
  const morceaux = [];
  req.on("data", (m) => morceaux.push(m));
  req.on("end", async () => {
    const brut = Buffer.concat(morceaux).toString("utf8");
    const repondre = (code, corps) => { res.writeHead(code, { "content-type": "application/json" }); res.end(JSON.stringify(corps)); };
    if (req.method !== "POST") return repondre(405, { ok: false, error: "methode" });
    if (!signatureValide(req.headers["x-kortex-timestamp"], brut, req.headers["x-kortex-signature"])) {
      return repondre(401, { ok: false, error: "signature" });
    }
    const e = JSON.parse(brut);
    if (vus.has(e.id_evenement)) return repondre(200, vus.get(e.id_evenement));
    if (e.evenement === "test") return repondre(202, { ok: true, etat: "ok", adresse: "https://www.exemple.fr/", commit: null });

    const c = e.contenu;
    if (c.version < (versions.get(c.id) ?? 0)) return repondre(409, { ok: false, error: "version_ancienne", version_connue: versions.get(c.id) });
    // Vos règles : refusez en 400 avec un message clair.
    if (e.evenement === "approve" && !c.corps?.texte) return repondre(400, { ok: false, error: "corps_invalide", message: "corps vide" });

    // … appliquer : écrire `guides/${c.slug}.md`, committer, reconstruire …
    const reponse = { ok: true, etat: e.evenement === "publish" ? "publie" : "brouillon", adresse: `https://www.exemple.fr/guides/${c.slug}/`, commit: "abc1234", delai_estime_s: 180 };
    versions.set(c.id, c.version);
    vus.set(e.id_evenement, reponse);
    repondre(202, reponse);
  });
}).listen(8080);
```

## Pour une IA chargée d'implémenter le service

Copiez ce paragraphe dans votre assistant :

> Implémente un endpoint `POST` qui reçoit les appels du connecteur de
> publication webhook de Kortex, contrat v1, décrit sur
> https://kortex.center/modules/publication-webhook.md. Vérifie la signature
> `X-Kortex-Signature` (HMAC-SHA256 de `X-Kortex-Timestamp + "." + corps
> brut`, secret en variable d'environnement, comparaison à temps constant,
> fenêtre de 300 s) et reproduis d'abord le vecteur de test de la page.
> Garde les `id_evenement` 7 jours (rejeu → `200` + réponse d'origine).
> Refuse en `409` une version inférieure à la dernière acceptée. Pour chaque
> événement (`approve`, `publish`, `depublier`, `renommer_adresse`, `entete`,
> `test`), applique l'effet décrit dans le tableau « Événements » à notre
> site [décrire : générateur, dossier des contenus, en-tête des fichiers,
> préfixe des brouillons]. Réponds selon le tableau « Réponses ». Écris les
> tests : vecteur de signature, horodatage hors fenêtre, rejeu, version
> ancienne, chaque événement.

## Questions fréquentes

**Kortex peut-il écrire directement dans notre dépôt ?** Pas avec ce
connecteur, et c'est voulu : il n'a besoin d'aucun accès Git. Un mode
« direct » (branche dédiée et demande de fusion) est prévu pour les sites
sans service.

**Qui décide de ce qui est publié ?** L'équipe, dans l'espace. Votre service
garde le dernier mot : il refuse ce qui ne respecte pas vos règles.

**Que se passe-t-il si notre service tombe ?** Kortex réessaie pendant
environ 7 h, dans l'ordre, puis attend un geste d'un administrateur. Rien
n'est perdu, rien ne part dans le désordre.

**Le superviseur de l'espace écrit-il aussi sur le site ?** Non. Avec le
connecteur webhook, l'espace est le seul écrivain ; les agents continuent de
lire le site (pages publiques, Search Console) et de rédiger.

**Comment changer le secret ?** Collez le nouveau dans Réglages ›
Publication : l'ancien n'est plus jamais utilisé. Mettez votre service à
jour au même moment (ou acceptez les deux pendant quelques minutes).

Voir aussi : [le guide de l'espace](/modules/guide-espace/) ·
[l'API des espaces](/modules/dashboard-api/) ·
[l'API de kortex.center](/aide/api/).
