Écrire un connecteur pour la marketplace
Un service qui se connecte avec Spunto CMS, reçoit les webhooks d'un espace et se publie dans la marketplace — OAuth avec PKCE, webhooks à soi, vérification des signatures.
Un connecteur est votre service, à votre adresse, qu'un admin d'espace installe depuis la marketplace. Slack et Google Drive sont construits exactement ainsi, sans rien qu'un tiers ne puisse utiliser. Cette page suit le même chemin.
À l'arrivée : un bouton « Se connecter avec Spunto CMS », un jeton limité à un espace, un webhook qui n'est qu'à vous, et une fiche dans Réglages → Marketplace de chaque espace de l'instance.
1. Enregistrer l'app
Ouvrez Développeurs (/app/developpeurs, en pied de la liste de vos
espaces) et enregistrez une app :
- URI de redirection — où le consentement renvoie le navigateur, par exemple
https://mon-connecteur.exemple/oauth/callback. Comparée à l'identique, jamais par préfixe. HTTPS, sauf surlocalhost. - Scopes — ce que l'app pourra demander. L'admin qui l'installe peut accorder moins, jamais plus.
Le client_secret ne s'affiche qu'une fois. Gardez-le côté serveur.
Demandez ce dont vous avez besoin, rien de plus. Un connecteur qui poste dans un
chat a besoin de items:read, schema:read (noms des listes et des statuts),
events:read, webhooks:read et webhooks:write — et du rôle viewer, puisqu'il
n'écrit rien.
2. Lancer le flux depuis l'URL d'installation
Le bouton Installer de la marketplace est un simple lien vers votre URL
d'installation, suivie de ?workspace_id=… : l'espace d'où vient la personne.
C'est là que vous lancez OAuth, parce que vous seul pouvez garder le verifier
PKCE jusqu'au retour du code.
const verifier = base64url(randomBytes(32))
const challenge = base64url(sha256(verifier))
const state = base64url(randomBytes(16))
// Garder { state, verifier } dans un cookie signé, httpOnly, ~10 minutes.
const url = new URL("https://cms.spunto.net/install")
url.searchParams.set("client_id", CLIENT_ID)
url.searchParams.set("redirect_uri", "https://mon-connecteur.exemple/oauth/callback")
url.searchParams.set("scope", "items:read schema:read events:read webhooks:read webhooks:write")
url.searchParams.set("state", state)
url.searchParams.set("code_challenge", challenge)
url.searchParams.set("code_challenge_method", "S256")
url.searchParams.set("role", "viewer") // un indice
if (workspaceId) url.searchParams.set("workspace_id", workspaceId) // un indice
redirect(url)role et workspace_id sont des indices : l'écran de consentement les
présélectionne, l'admin choisit, et l'API plafonne le rôle au sien.
3. Échanger le code
Vérifiez state contre votre cookie avant toute autre chose, puis échangez
le code :
const { data } = await createSpuntoCmsClient({ baseUrl: API }).POST("/oauth/token", {
body: {
grantType: "authorization_code",
code, clientId: CLIENT_ID, clientSecret: CLIENT_SECRET,
codeVerifier: verifier,
redirectUri: "https://mon-connecteur.exemple/oauth/callback",
},
})
// data.accessToken (spa_…), data.workspaceId, data.installationId, data.scope, data.roleLe jeton n'expire pas : sa durée de vie, c'est l'installation. Stockez-le chiffré. Se reconnecter plus tard rejoue le même flux : l'API renouvelle le jeton sur la même installation.
4. Brancher votre webhook
const api = createSpuntoCmsClient({ baseUrl: API, token: accessToken })
const { data: hook } = await api.POST("/workspaces/{workspaceId}/webhooks", {
params: { path: { workspaceId } },
body: { name: "Mon connecteur", url: "https://mon-connecteur.exemple/webhooks/spunto", eventTypes: ["item.*"] },
})
// hook.secret n'est rendu qu'une fois — à stocker chiffré, par espace.Une app n'a pas besoin du rôle admin pour ça, mais il lui faut events:read en
plus de webhooks:write : un webhook, c'est le journal poussé. L'endpoint
appartient à votre installation — vous ne voyez que les vôtres, aucune autre
app ne peut y toucher, et il disparaît quand l'espace vous désinstalle.
5. Recevoir les livraisons
import { SIGNATURE_HEADER, verifyWebhookSignature, type WebhookPayload } from "@spunto-cms/client"
const raw = await request.text() // le corps BRUT
const payload = JSON.parse(raw) as WebhookPayload
const secret = await secretFor(payload.workspaceId) // le corps dit seulement QUEL secret
if (!(await verifyWebhookSignature(secret, raw, request.headers.get(SIGNATURE_HEADER)))) {
return new Response("signature refusée", { status: 401 })
}
// Idempotent sur payload.event.id (ou batch.batchId), répondre 2xx vite.Trois habitudes rendent un connecteur supportable :
- ignorer les événements dont la
causevaut"import"— un import n'est pas une nouvelle ; - une livraison
kind: "batch"est une édition en masse : en faire un message ; - si votre fournisseur échoue (un canal Slack supprimé), le noter et répondre quand même 200. Répondre 5xx fait rejouer l'API, puis mettre votre endpoint en pause pour tout l'espace.
Quand un appel répond 401, l'espace vous a désinstallé. Arrêtez-vous, gardez les réglages, et laissez une nouvelle connexion réinstaller.
6. Publier
De retour sur Développeurs, remplissez la fiche — adresse, accroche, catégorie, éditeur, URL d'installation ; description et icône sont facultatives — et cliquez Publier. S'il manque quelque chose, l'erreur le dit.
Le badge Vérifiée est posé par l'exploitant de l'instance, pas par vous : le nom d'éditeur est libre, et c'est le badge qui distingue « Slack par Spunto » d'un imposteur. Une fiche non vérifiée le dit, et rappelle de regarder où mène le bouton d'installation.
Notion comme CMS headless : ce qui coince, et ce qui change
Un site qui lit l'API Notion : limites de débit, blocs imbriqués, URLs de fichiers qui expirent, webhooks sans contenu — et la réponse de Spunto CMS.
Relier GitHub à vos cartes
Le connecteur GitHub rattache pull requests et commits aux cartes qu'ils citent, fait suivre les statuts au code, et importe les issues d'un dépôt dans une liste — sans doublon au second passage.