Webhooks

Deux niveaux d'événements, une signature HMAC sur le corps brut, huit tentatives avec backoff - et un événement qui garde un frontend headless à jour.

Un webhook, c'est la façon dont cmssy dit à votre application que quelque chose a changé, au lieu que votre application demande. Il y a deux niveaux d'événements, une forme de livraison, et un détail plus important que le reste : content.changed transforme une publication en page en ligne sur un frontend mis en cache.

Deux niveaux, deux questions

Les niveaux répondent à des questions différentes, et la plupart des intégrations n'en utilisent qu'un.

  • content.changed - « ce que je sers a changé, invalidez votre cache ». Un seul événement large. C'est ce qu'un frontend headless abonne.
  • Événements d'édition - « quelqu'un a fait quelque chose ». Vingt et un événements granulaires pour l'automatisation, l'audit et les notifications : un message Slack à la publication d'une page, une fiche CRM à l'envoi d'un formulaire.
  • order.* - dix événements du cycle de vie des commandes.

list_webhook_event_types est la liste blanche faisant autorité, filtrée selon vos permissions - lisez-la plutôt que de deviner un nom.

content.changed, pour l'invalidation de cache

Un seul événement couvre tout changement de ce que l'API de livraison sert, et la charge utile dit ce qui s'est passé :

{
  "kind": "page",          // page | record | model | form | media | settings
  "action": "published",   // published | unpublished | created | updated | deleted
  "ids": ["6a63..."],
  "slug": "/pricing",      // pages uniquement
  "modelSlug": null         // enregistrements et modèles uniquement
}

Abonnez-vous une fois et traitez toute paire comme « invalider ». C'est l'intérêt de cette largeur : un nouveau type de sujet ajouté plus tard vous parvient sans nouvel abonnement.

Trois cas limites à coder. Une opération en masse dont l'ensemble touché est inconnu ou dépasse 100 envoie un tableau ids vide : traitez-le comme « tout invalider ». Pour une suppression groupée de pages, slug est la racine du sous-arbre supprimé ; les descendants listés dans ids vivaient sous d'autres slugs. Et kind: "settings" porte toujours un ids vide et un slug nul : rien ne désigne une page précise, donc invalidez tout l'arbre.

Supprimer un modèle émet deux événements : un pour la définition et un pour les enregistrements partis avec elle. Un consommateur qui met en cache les définitions et un autre qui met en cache les enregistrements n'ont pas les mêmes choses à invalider.

Le niveau édition

Ils ne remplacent pas content.changed : ils répondent à l'autre question. Abonnez-vous pour l'automatisation, pas pour l'invalidation de cache.

  • Pages - page.created, page.updated, page.deleted, page.published, page.unpublished.
  • Enregistrements - record.created, record.updated, record.deleted.
  • Modèles - model.created, model.updated, model.deleted.
  • Formulaires - form.created, form.updated, form.deleted, form.submitted.
  • Médias - media.uploaded, media.updated, media.deleted.
  • Membres - member.added, member.updated, member.removed.
  • Réglages - settings.updated.

Ils portent la même forme de charge utile que content.changed, un seul parseur suffit donc pour les deux niveaux. Les charges utiles sont des références légères, jamais du contenu : form.submitted vous donne l'id de la soumission et le slug du formulaire, pas les valeurs envoyées - récupérez-les via l'API avec vos propres identifiants.

Deux asymétries sont volontaires. page.updated se déclenche pour un changement au niveau du document, que la page soit publiée ou non, mais seule une page publiée émet en plus content.changed : éditer un brouillon non publié est un travail d'édition, pas un changement de ce qui est servi. Et media.uploaded est purement granulaire : un asset créé il y a une seconde ne peut être référencé par aucune page publiée, il n'y a donc rien à invalider. Le modifier ou le supprimer émet bien content.changed.

Quelles permissions pour un abonnement

Abonner un point de terminaison à un événement exige la permission de lire ce que l'événement décrit, en plus de webhooks:manage. Un abonnement mixte les exige toutes.

  • order.* - orders:view
  • page.* - pages:view
  • record.*, model.* - models:view
  • form.created|updated|deleted - forms:view
  • form.submitted - forms:submissions:view
  • media.* - media:view
  • member.* - users:view
  • settings.updated - site:config:edit

content.changed peut porter n'importe lequel des types livrés, il exige donc l'union de ce que ces types demandent : pages:view + models:view + forms:view + media:view. Le même ensemble est vérifié lorsque vous modifiez un point de terminaison existant : un rôle qui ne peut pas s'abonner à un événement ne peut pas non plus maintenir en vie un point de terminaison qui le porte.

Garder un frontend en cache à jour

Publier ne déploie pas, et ne contourne pas non plus votre cache. Une page en revalidate = 3600 continue de servir l'ancienne copie jusqu'à une heure tant que rien ne l'invalide. Ce quelque chose est un webhook content.changed pointé vers une route de revalidation :

// app/api/revalidate/route.ts
import { createCmssyRevalidateRoute } from "@cmssy/next/server";

export const POST = createCmssyRevalidateRoute({
  secret: process.env.CMSSY_WEBHOOK_SECRET,
});

npx @cmssy/cli init écrit exactement cette route. Elle vérifie la signature de la livraison et fait expirer tout ce qui est en cache sous le tag cmssy-content, pour que le visiteur suivant rende le contenu publié ; placez le secret de signature de Settings → Webhooks dans CMSSY_WEBHOOK_SECRET. Vous écrivez votre propre route ? Vérifiez d'abord le corps avec verifyCmssyWebhook de @cmssy/core - les notes ci-dessous s'appliquent alors.

Si vos URL portent un préfixe de langue, revalidez aussi les chemins localisés - chacun est mis en cache séparément. Et si votre navigation, votre sitemap ou vos listes parentes sont mises en cache sous un tag, videz ce tag à chaque événement : une page fraîchement publiée est en ligne mais absente de chaque menu tant que vous ne le faites pas.

Préférez l'invalidation par sujet à une reconstruction complète quand c'est possible. subject.ids vous donne les enregistrements exacts qui ont changé pour les écritures unitaires : en taguant vos requêtes par id d'enregistrement, vous rafraîchissez une fiche produit plutôt que tout le site.

À quoi ressemble une livraison

Chaque livraison est un POST avec content-type: application/json et ce corps :

{
  "id": "6a64...",                       // l'id du point de terminaison
  "event": "content.changed",
  "createdAt": "2026-07-25T18:30:00.000Z",
  "data": { "workspaceId": "...", "subject": { } }
}

Trois en-têtes l'accompagnent : x-cmssy-event, x-cmssy-webhook-id et x-cmssy-signature.

Vérifier la signature

L'en-tête de signature s'écrit t=<unix-ms>,v1=<hex>, où le hex est un HMAC-SHA256 sur <t>.<corps brut> clé par le secret du point de terminaison. Pendant une rotation de secret, l'en-tête porte un v1 par secret actif : un vérificateur doit donc accepter une correspondance avec n'importe lequel d'entre eux - ne contrôler que le dernier fait silencieusement tomber la moitié des livraisons en pleine rotation.

Utilisez l'utilitaire fourni par le SDK plutôt que d'écrire cela à la main. Il lit chaque v1, compare en temps constant, rejette un horodatage de plus de 5 minutes - une livraison interceptée n'est donc pas rejouable - et renvoie l'événement typé. Il est exporté depuis @cmssy/next, @cmssy/remix et @cmssy/astro (et depuis @cmssy/core si vous n'utilisez aucun des trois) :

import { verifyCmssyWebhook, CmssyWebhookError } from "@cmssy/next";

export async function POST(request: Request) {
  const body = await request.text();

  try {
    const event = await verifyCmssyWebhook({
      body,
      signatureHeader: request.headers.get("x-cmssy-signature"),
      secret: process.env.CMSSY_WEBHOOK_SECRET!,
    });

    handle(event);
    return new Response(null, { status: 204 });
  } catch (error) {
    if (error instanceof CmssyWebhookError) {
      return new Response(null, { status: 400 });
    }
    throw error;
  }
}

Passez le corps brut - await request.text(), jamais un objet re-sérialisé. Parser puis re-stringifier du JSON change les octets et casse la comparaison.

Relances

cmssy attend 5 secondes une réponse et ne suit pas les redirections : un 3xx est une tentative échouée, pas un saut - enregistrez l'URL finale. Tout ce qui n'est pas un 2xx, plus les timeouts, est relancé jusqu'à 8 tentatives, avec des paliers de 1 min, 5 min, 15 min, 30 min, 1 h, 2 h, 4 h.

Seul 410 Gone interrompt l'échelle plus tôt. Tout autre 4xx est relancé, car "ce point de terminaison est fini pour de bon" et "le déploiement qui sert cette route répond brièvement 404" sont indiscernables de l'extérieur. Renvoyez 410 quand c'est ce que vous voulez dire.

Un en-tête Retry-After - secondes ou date HTTP - est respecté lorsqu'il demande plus que ce que l'échelle attendrait, plafonné à 4 heures. Il ne raccourcit jamais le backoff.

Après 20 échecs consécutifs, un point de terminaison est désactivé automatiquement et ne reçoit plus de livraisons. Corrigez le handler, puis réactivez-le avec update_webhook, ce qui remet le compteur à zéro.

Deux conséquences pour votre handler. Il doit être rapide : accusez réception par un 2xx et faites le travail ensuite, sinon un point de terminaison lent se transforme en tempête de relances. Et il doit être idempotent : dédupliquez sur x-cmssy-webhook-id, identique à chaque tentative d'une même livraison - tout comme le createdAt du corps.

list_webhook_deliveries montre les tentatives récentes avec leur statut et leur code de réponse ; les livraisons sont conservées 30 jours.

Gérer les points de terminaison

  • create_webhook - renvoie le point de terminaison et son secret, une seule fois. Stockez-le tout de suite ; il ne reviendra jamais.
  • rotate_webhook_secret - renvoie un nouveau secret, une seule fois également. Une rotation depuis l'interface d'administration laisse l'ancien secret signer en parallèle pendant 24 heures, vous pouvez donc redéployer sans perdre de livraison ; une rotation via MCP bascule immédiatement. Dans les deux cas, l'ancien secret cesse de vérifier dès la fin de sa fenêtre.
  • update_webhook - mise à jour partielle ; passez enabled pour suspendre sans supprimer. Désactiver n'exige jamais les permissions d'événement : un point de terminaison compromis peut toujours être éteint.
  • list_webhooks, delete_webhook, list_webhook_deliveries.

Jusqu'à 20 points de terminaison par espace de travail. Les URL doivent être en https en production, et les cibles privées sont rejetées : localhost et ses sous-domaines, *.local, *.internal, les littéraux IPv6, ainsi que les plages IPv4 qui ne sont pas l'internet public - RFC 1918, loopback, link-local, CGNAT (100.64/10), benchmarking (198.18/15) et tout à partir de 224.0.0.0. Le nom d'hôte est en outre résolu avant chaque livraison : un nom public qui répond par une adresse privée est donc rejeté lui aussi. Un webhook capable d'atteindre votre réseau interne est une surface SSRF, pas une fonctionnalité.

Étapes suivantes

  • Serveur MCP - les outils qui créent et inspectent les points de terminaison.
  • Aperçu brouillon - publication, cache et ce que signifie une page périmée.
  • Jetons API - l'identifiant derrière ces outils.