Spunto CMS
L'API

Événements, webhooks et apps

Suivre les changements d'un CMS headless depuis un autre logiciel — un journal d'événements rejouable, des webhooks vérifiables, des apps à scopes.

Tout ce qui change dans un espace de travail est écrit dans un journal d'événements, et une intégration peut le suivre de deux façons : le lire par curseur, ou se le faire pousser sur un webhook.

Les deux existent, et l'ordre compte : le journal est le mécanisme, les webhooks sont la commodité. Si un webhook est manqué, le journal est toujours là.

Le journal

Les événements sont écrits dans la transaction qui modifie la donnée, jamais depuis un handler HTTP.

C'est la même règle que partout ailleurs dans ce produit — la barrière est à l'écriture, à un seul endroit — et elle a une conséquence qui mérite d'être énoncée comme une garantie :

Un événement existe si et seulement si le changement a été commité.

Émettre depuis HTTP aurait raté les données d'amorçage, les imports, la suppression de champs et les éditions en masse. Cela fait aussi du journal une affaire de modèle de données plutôt que de logs : les événements sont soumis à la sécurité au niveau des lignes, et UPDATE est révoqué sur la table. Un événement ne se réécrit pas.

Ce que porte un événement

{
  "id": "0192…",
  "occurredAt": "2026-09-13T09:12:44.000Z",
  "type": "item.updated",
  "resource": { "kind": "item", "id": "…" },
  "actor": { "kind": "api_key", "label": "Site build", "apiKeyId": "…" },
  "changes": { "statusId": { "before": "…", "after": "…" } },
  "batchId": null,
  "cause": "edit"
}
  • changes contient les clés qui ont réellement changé, avec leur avant et leur après — jamais la ressource entière. Une propriété du registre y apparaît sous la forme properties.<key>.
  • l'acteur est figé, libellé compris. Révoquez une clé, et le journal dit toujours laquelle a agi.
  • cause vaut edit ou import. ?cause=edit est ce que veut un fil d'activité — sans cela, un seul import de trois mille cartes enterre six mois de travail.
  • batchId est non nul quand cet événement fait partie de plusieurs issus d'une même action.

Types d'événements

item.created, item.updated, item.moved, item.deleted, item.attached, item.detached, item.published, item.unpublished, collection.created, collection.updated, collection.deleted, collection_group.created, collection_group.updated, collection_group.deleted, field.created, field.updated, field.deleted, status.created, status.updated, status.deleted, view.created, view.updated, view.deleted, workspace.updated.

C'est une liste fermée, publiée dans la spécification, parce qu'un webhook s'abonne par motif et qu'un consommateur doit pouvoir savoir à quoi il peut s'abonner sans lire notre code.

workspace.updated n'a pas de pendant created : le journal vit à l'intérieur de l'espace de travail, qui n'existe donc pas encore au moment où il est créé.

Le lire

GET /workspaces/{workspaceId}/events?since=<eventId>&type=item.updated&order=asc

Le curseur est l'identifiant de l'événement, et il est lisible à dessein — la seule exception à « les curseurs sont opaques ».

La règle contre les curseurs lisibles visait les offsets ; un keyset sur un UUID ordonné dans le temps n'en est pas un. Et un consommateur qui vient de recevoir un webhook a un événement en main et doit pouvoir dire « reprends après celui-là ». Le mécanisme de reprise doit être la chose la plus simple du système, parce que c'est celle qu'on attrape quand quelque chose vient de casser.

since est exclusif. order=asc est l'ordre propre du journal, du plus ancien au plus récent — ce que veut une intégration ; desc est pour un fil d'activité. Filtrez par type (répétez le paramètre pour en ajouter), par batchId ou par cause.

Webhooks

POST   /workspaces/{workspaceId}/webhooks
GET    /workspaces/{workspaceId}/webhooks/{endpointId}/deliveries
POST   /workspaces/{workspaceId}/webhooks/{endpointId}/resume
POST   /workspaces/{workspaceId}/webhooks/{endpointId}/deliveries/{deliveryId}/replay

Un point de terminaison a une URL et une liste d'eventTypes. Trois formes exactement sont acceptées — * pour tout, item.* pour une famille, ou un type exact — et cette pauvreté est voulue : un langage de motifs qu'on ne peut pas expliquer en une phrase est un langage où personne ne sait à quoi il s'est abonné. Un motif qui ne correspond à aucun type connu est refusé à la création, plutôt que de ne jamais se déclencher en silence.

Le filtrage a lieu quand une livraison est mise en file, pas quand elle est envoyée.

Vérifier une livraison

Le secret de signature est renvoyé une seule fois, à la création du point de terminaison.

X-Spunto-Signature: t=<unix seconds>,v1=<hmac-sha256 of "t.body">

L'horodatage est dans la signature. Sans lui, une livraison interceptée se rejoue indéfiniment.

Calculez le HMAC sur la chaîne littérale `${t}.${rawBody}` avec votre secret, comparez en temps constant, et rejetez un horodatage trop ancien à votre goût.

Nouvelles tentatives, et ce qui se passe quand votre point de terminaison est en panne

Une livraison échouée est retentée cinq fois, avec des délais croissants de 10 s, 30 s, 2 min, 10 min, 1 h — six tentatives au total. Chaque requête expire au bout de 10 secondes, pour qu'un point de terminaison lent ne retienne pas les autres.

Après 10 échecs consécutifs, le point de terminaison est mis en pause.

Cette pause est sans danger précisément parce que la lecture par curseur existe : rien n'est perdu pendant la pause, tout reste lisible dans le journal. Sans elle, un point de terminaison mort ferait grossir la file indéfiniment et mangerait le budget de livraison de ceux qui répondent.

POST …/resume le sort de pause et rend ses livraisons en attente exigibles immédiatement — sans quoi le point de terminaison serait de nouveau actif pendant que sa file attendrait encore la fin d'un délai calculé pendant l'incident.

GET …/deliveries montre les tentatives, les codes de statut et les dernières erreurs. N'importe laquelle peut être rejouée.

Lots

Une édition en masse ou un import n'envoie pas un webhook par carte. Ils envoient une seule livraison pour le lot :

{
  "kind": "batch",
  "type": "item.updated",
  "batch": {
    "batchId": "…",
    "count": 3000,
    "read": { "batchId": "…", "path": "/workspaces/…/events?batchId=…&type=item.updated" }
  }
}

En dessous de 50 événements, les événements individuels sont inclus dans events ; au-delà, vous suivez read.path. Le type figure dans cette requête parce qu'un lot peut en porter plusieurs — un import crée et met à jour dans le même souffle, et un point de terminaison abonné au seul item.created ne doit pas recevoir les mises à jour.

Apps et scopes

Un logiciel tiers s'installe dans un espace de travail par OAuth et reçoit un jeton (spa_…) qui porte un rôle et un ensemble de scopes. Il ne peut jamais dépasser ni l'un ni l'autre.

Les scopes se définissent par famille de ressources × lecture/écriture : items:read, items:write, items:publish (le seul scope plus fin : mettre une carte en ligne, voir publication), schema:read, schema:write, members:read, files:read, files:write, events:read, webhooks:read, webhooks:write. GET /apps/scopes les liste avec leurs libellés.

Deux décisions bonnes à connaître :

La table route → scope est fail-closed. Une route qu'elle ne nomme pas est refusée à toutes les apps. Une heuristique « GET signifie lecture » aurait cassé dès le premier POST …/query — qui lit —, et chaque route de lecture atteinte par POST est donc nommée individuellement.

Certaines routes sont réservées aux humains. Gérer les clés API, les invitations, les membres ou les installations d'apps exige une session. Une app capable de s'installer elle-même n'aurait pas besoin d'être installée.

Un jeton d'app n'expire pas. Sa durée de vie, c'est l'installation — révocable à elle seule, et qui survit au départ de la personne qui l'a faite.

Les webhooks d'une app sont à elle. Une app gère des endpoints avec webhooks:write et events:read, quel que soit son rôle ; elle ne voit que ceux qu'elle a créés, et ils disparaissent avec son installation. Un admin les voit tous, avec l'app à qui chacun appartient.

Les apps publiées sont dans Réglages → Marketplace ; en écrire une est décrit dans Écrire un connecteur.

Sur cette page