
La partie qui ressemble à Notion.
Des listes de cartes, un registre de propriétés typées que votre équipe déclare, et autant de vues enregistrées que de questions. Rien ici n'a été dessiné pour une landing : ce sont des captures de l'app.
tableau
Un tableur qui connaît ses types.
Chaque cellule s’édite sur place — un statut, une personne, une date, des étiquettes — et la valeur est vérifiée contre le type de la propriété avant d’être écrite, pas quand une vue essaie plus tard de la lire.
- Les colonnes se choisissent, s’ordonnent et se dimensionnent par vue, et s’enregistrent avec elle.
- Les flèches pour se déplacer, Entrée pour éditer, Espace pour sélectionner ; une ligne nouvelle, c’est un titre et Entrée.
- Les sous-cartes se rangent sous leur parent, avec la clé du parent à côté.

carte
Chaque carte est un document.
Ouvrez une carte et vous obtenez une page, pas une zone de texte : une couverture, une icône, des titres, des cases à cocher, des encadrés, du code et des images — avec ses propriétés sur le côté et ses sous-cartes en dessous.
- Elle s’enregistre seule 1,2 s après la dernière frappe. Pas de bouton Enregistrer à oublier.
- Un format de blocs aux marques nommées — il ne peut pas transporter de HTML, donc il n’y a rien à y assainir quand votre site l’affiche.
- Dupliquer, archiver ou copier son lien depuis le menu ; le fil d’Ariane dit où elle vit.

vues
Une liste, autant de vues que de questions.
Un kanban pour la réunion de la semaine, un tableau pour le calendrier éditorial, une liste groupée par rubrique pour la newsletter. Les mêmes cartes, trois vues enregistrées — chacune un filtre, un tri et un regroupement.
- Groupez par statut, par un select ou par personne ; les colonnes vides restent affichées, pour qu’on puisse y déposer une carte.
- Glisser une carte dans une autre colonne écrit la nouvelle valeur — c’est une modification, pas un effet d’affichage.
- La définition que votre équipe enregistre est celle que votre site peut envoyer à l’API.

ce que contient un espace
Pas d'assigné ni d'échéance intégrés : une carte porte un titre, un statut et une place dans un arbre, et le reste est ce que votre équipe déclare — une base de contenu n'est pas un gestionnaire de tâches.
- listes
- Des listes de cartes avec une clé lisible —
SPR-12— numérotée par l’API à partir du préfixe de la liste. Une carte peut être rattachée à plusieurs listes à la fois sans quitter celle où elle est née. - propriétés
- Dix types, définis par espace et réutilisés d’une liste à l’autre :
text,number,select,multi_select,date,checkbox,url,person,multi_person,file. Ils sont validés à l’écriture, pas à la lecture — une valeur mal typée est refusée à la porte plutôt que de casser plus tard toutes les vues qui la lisent. - corps des cartes
- Le corps d’une carte est un document en blocs : titres, listes, cases à cocher, citations, encadrés, code, séparateurs et images. Les marques sont nommées (
bold,italic,code,strike,link), donc le format ne peut pas transporter de HTML et il n’y a rien à y assainir. - hiérarchie
- Les cartes s’imbriquent. Les sous-tâches, un sous-arbre entier ou les ancêtres d’une carte s’obtiennent chacun en une requête, et déplacer une carte réécrit son sous-arbre au lieu de l’orpheliner.
- vues
- Une vue enregistrée est une donnée, jamais un fragment de SQL : un arbre de filtres, des tris, un regroupement. La même définition pilote l’écran, l’API et l’édition en masse — compilée en SQL dans un seul et unique fichier.
- statuts
- Votre propre workflow —
Idée,Relecture,Prêt— chacun avec une couleur tirée d’une palette fermée de huit, partagée avec les options des select : une étiquette verte est le vert du statut vert d’à côté. - recherche
⌘Kdans toutes les listes : titres des cartes et noms des listes, et une clé exacte commeBLG-12remonte en tête.- fichiers
- Les pièces jointes et les blocs image portent un identifiant de fichier, jamais une URL. L’API sert elle-même les octets depuis une route scopée : ni bucket public, ni lien signé à faire suivre.
- depuis Notion
- Un import qui se relance : ce qui n’a pas changé n’est pas réécrit, donc un import à moitié raté se répare en le relançant.
Votre site lit les mêmes lignes.
Pas d'export, pas de second stockage à garder synchronisé. Une requête renvoie les cartes qu'un rédacteur modifie — ou, si elle demande la version publiée, exactement ce qui a été publié en dernier, pendant que le brouillon suivant s'écrit.
import createClient from "openapi-fetch"
// types generated from /openapi.json by openapi-typescript
import type { paths } from "./spunto-api"
// SPUNTO_TOKEN holds an API key: spk_…, one workspace
const api = createClient<paths>({
baseUrl: "https://cms.spunto.net/api",
headers: { Authorization: `Bearer ${SPUNTO_TOKEN}` },
})
// STAGE and DUE_DATE are ids from the workspace registry. Nothing but the
// title, the body and the status is built in — the rest your team declares.
// A view is data — a filter tree, sorts, an optional group-by.
const definition = {
filter: {
and: [
{ op: "eq", field: { fieldId: STAGE }, value: "shipped" },
{ op: "is_not_empty", field: { fieldId: DUE_DATE } },
],
},
sort: [
{ field: { fieldId: "sys:created_at" }, direction: "desc" },
],
}
const { data } = await api.POST(
"/workspaces/{workspaceId}/collections/{collectionId}/query",
{ params: { path: { workspaceId, collectionId } },
body: { definition, limit: 50 } }
)
data.rows // "SPR-12", its title, its typed properties
data.nextCursor // keyset — no page number to ask forLe client est généré depuis le fichier OpenAPI versionné : noms des routes, opérateurs de filtre et forme d’une ligne sont tous vérifiés par tsc — une propriété renommée est une erreur de build, pas une page blanche en production.
la surface
- protocole
- REST. Pas « à peu près REST », et jamais GraphQL — c’est tranché, pas en suspens.
- spec
- OpenAPI, générée depuis les routes annotées et versionnée dans le dépôt. Servie en direct sur
/openapi.json, avec la référence interactive sur/api/docs. - docs
/fr/docsest la moitié écrite — le modèle, les définitions de vues, le journal d’événements, et une page qui dit ce que le produit ne fait pas. La référence est générée ; celle-là ne l’est pas.- client
- Générez-en un depuis cette spec avec
openapi-typescript— des types complets pour chaque route, en une commande. C’est ainsi qu’est construit le client de cette app : une route manquante est ajoutée à la spec et regénérée, jamais écrite à la main. - pagination
- Keyset, toujours.
limitplafonne à 200 et la réponse rend unnextCursoropaque ; il n’y a aucunOFFSETdans le produit, donc la page 900 coûte ce que coûte la page 1. - lire une vue
POST …/collections/{id}/queryprend une définition de vue et renvoie ses lignes.…/group-countsrenvoie les comptes par groupe pour un kanban, et…/index-hintsindique quel index cette vue aimerait avoir.- auth
- Un seul en-tête
Authorization: Bearer. Un appel anonyme reçoit un 401, jamais une liste silencieusement vide.
Un agent est un auteur à part entière.
Beaucoup de CMS headless vous liront n'importe quoi en supposant qu'un humain l'a tapé. Ici, le chemin d'écriture est le même : un script, une tâche planifiée ou un agent LLM crée des cartes, renseigne des propriétés typées, écrit un corps structuré et modifie en masse avec le même client et la même clé qu'une session humaine.
Ça ne tient que si l'écriture est traçable : le journal d'événements n'est donc pas une fonctionnalité posée au-dessus de HTTP, il est écrit dans la transaction qui modifie la ligne. Un import, un seed, un champ supprimé et une mise à jour en masse de 3 000 cartes laissent la même trace qu'un clic, parce qu'aucun ne peut être validé sans elle.
ce qu'on peut faire à un espace
- cartes
- Créer, modifier, déplacer et archiver par les mêmes routes que l’UI. Écrivez
blockspour poser un corps structuré, oubodypour donner du texte brut et laisser l’API en dériver le document. - édition en masse
- Un seul
UPDATE … WHEREsur une définition de vue, prévisualisé d’abord. Au-delà de 200 cartes il faut passer le nombre attendu ; au-delà de 5 000 l’API refuse net plutôt que de faire semblant d’être atomique. - journal d'événements
- Écrit dans la transaction qui a modifié la donnée : un événement existe si et seulement si le changement a été validé. Il enregistre les clés modifiées avec leur avant et leur après — pas la ressource entière.
- webhooks
- Des points de livraison avec leur historique, une livraison rejouable à la demande, et un point suspendu que l’on réactive une fois réparé.
- apps OAuth
- Une app installée reçoit son propre jeton et ne peut dépasser ni son rôle ni ses scopes. La table route → scope est fermée par défaut : une route qu’elle ne nomme pas est refusée à toutes les apps.
// A body is blocks, and marks are named — the format
// cannot carry HTML, so there is nothing to sanitise.
const blocks = [
{ type: "heading", level: 2, text: [{ text: "What changed" }] },
{
type: "paragraph",
text: [
{ text: "Bulk edit now emits " },
{ text: "one event per card", bold: true },
],
},
{ type: "code", language: "bash", code: "npm i openapi-fetch" },
]
// Same client, same key an agent or a human would use.
const { data: item } = await api.POST(
"/workspaces/{workspaceId}/collections/{collectionId}/items",
{ params: { path: { workspaceId, collectionId } },
body: {
title: "Release notes — 0.4.0",
properties: { [STAGE]: "shipped", [TAGS]: ["api"] },
blocks: { blocks },
} }
)
item.key // "REL-18" — numbered by the API, not by the caller// Everything that changed the data left a row here: the UI,
// the seed, an import, and the script you just ran.
const { data: page } = await api.GET(
"/workspaces/{workspaceId}/events",
{ params: {
path: { workspaceId },
query: { since: lastProcessed, type: ["item.updated"] },
} }
)
for (const event of page.rows) {
event.type // "item.updated"
event.changes // only the keys that moved, before / after
event.actor // frozen: revoking a key doesn't rewrite history
}
page.nextCursor // an event id — hand it straight back as `since`Donner une clé reste une petite décision.
Une API de contenu n'est sereine qu'à la mesure de son rayon d'impact. Chaque table ici est indexée par espace, chaque index commence par lui, et la base elle-même refuse de rendre une ligne d'un tenant auquel la connexion n'est pas restreinte — une erreur dans le code applicatif est un bug, pas une fuite.
C'est ce qui rend le reste raisonnable : une clé pour votre site vitrine, une autre pour l'agent qui rédige le changelog, chacune liée à un espace et à un rôle dès son émission. Révoquez-en une, l'autre continue de fonctionner. Et comme les pièces jointes sont servies par l'API et non par un bucket, un lien qui s'échappe est un lien qui demande encore qui vous êtes.
comment l'accès se décide
- appartenance
- Le périmètre vient d’une appartenance vérifiée, jamais de l’URL. Nommer l’identifiant d’un espace ne donne pas le droit d’y entrer.
- 404, pas 403
- Un non-membre reçoit un 404. Un 403 confirmerait l’existence de l’espace à qui énumère des identifiants : le produit préfère ne pas répondre du tout.
- row-level security
- La RLS de Postgres en dessous, fermée par défaut, avec l’API connectée sous un rôle sans privilège — ni propriétaire des tables, ni superutilisateur, sinon les politiques ne protégeraient rien.
- fermé par défaut
- Les gardes sont posées sur le préfixe des routes et non route par route : une route nouvelle est protégée avant que quiconque pense à la protéger. Un test balaie toute la spec et exige un 401 partout, sauf sur une courte liste de portes, chacune justifiée sur sa ligne.
- clés API
- Une clé
spk_…porte son propre espace et son propre rôle, fixés à l’émission. Elle ne suit pas l’appartenance changeante de qui l’a créée, et la révoquer tient en un clic.
Qui peut entrer est une question de configuration.
Le code ferme par défaut et la configuration ouvre : une instance est sur invitation sauf si elle dit le contraire, et une invitation est un jeton émis par un admin, lié à une adresse e-mail et à un rôle, consommé par la transaction même qui crée l'appartenance. Celle-ci est ouverte.
C’est appliqué dans l’application et non par une barrière devant elle, et c’est justement ce qui permet à l’API d’être exposée sur internet pour que les intégrations l’atteignent. « Ouvrir l’app » mène à une seule page qui porte les deux moitiés, connexion et inscription — selon le réglage de l’instance.
à ce jour
- inscription
- Par instance. Le code est sur invitation par défaut — un jeton
spi_…lié à une adresse et à un rôle, consommé par la transaction même qui crée le compte — et celle-ci est ouverte. - vues
- Une définition porte un type — tableau, kanban, liste, calendrier — et l’UI web affiche les quatre, le calendrier au mois, à la semaine ou en chronologie. L’app iOS montre encore un calendrier en tableau.
- publication
- Une carte est un brouillon jusqu’à ce qu’on la publie. Avec
version: "published", une lecture ne rend que les cartes publiées, telles qu’elles l’ont été ; sans, elle rend le brouillon — votre aperçu. C’est un choix de lecteur, pas un droit : une clé qui lit peut lire les brouillons. Ni publication programmée, ni historique des versions. - langue
- L’application, cette page et la documentation existent en français et en anglais ; la référence de l’API est en anglais. Les noms que vous donnez aux listes, statuts et propriétés sont les vôtres, dans la langue où vous les écrivez.
à lire
Rien sur cette page n'est une entrée de roadmap. Chaque route citée ici est dans le fichier OpenAPI versionné, et tout ce qui est décrit tourne aujourd'hui derrière ce domaine. Quand quelque chose manque — publication programmée, recherche dans les corps — c'est dans la liste à droite plutôt que formulé comme si c'était livré. La version complète de cette liste est sur /fr/docs/limits.