Notion
Two directions, and only one of them is a loop.
Connect Notion and everyone who does not open Kanso reads the same data where they already work. It is a mirror, not a second source of truth, and this page is mostly about that difference.
The one decision everything follows from
Notion cannot sit between two people editing the same board. A call takes roughly 300–800 ms, the integration is capped near three requests a second, and there is no push worth relying on. Anything routed through it inherits those numbers.
Postgres is the operational source of truth. Notion is an asynchronous mirror. Kanso writes to Postgres, answers you, and pushes to Notion afterwards.
So Kanso stays fully usable while Notion is unreachable, and reconciles afterwards. Nothing you do in the app waits on a network call to somebody else's service.
Outbound, continuously
Every write enqueues a sync job in the same transaction as the business change, so a crash cannot lose one. A worker dequeues them with FOR UPDATE SKIP LOCKED, throttles itself with a token bucket, retries with exponential backoff, and writes into four databases:
- Kanso · Teams
- Kanso · Projects
- Kanso · Tickets
- Kanso · Docs
Those four are the whole of the mirror, and they are ordinary Notion databases. The people who only need to follow along filter, group and chart them the way they already do, and never open Kanso.
Jobs carry a dependency priority — team, then project, then ticket, then document — because a relation needs its target page to exist first. A push whose target has no page yet is deferred a few seconds rather than failed.
Inbound, by polling
Notion has no dependable push, so Kanso polls incrementally on last_edited_time, with a persisted cursor per data source and echo suppression so its own writes do not come back as changes.
The poller applies scalar fields only — title, status, priority, dates, the archived flag. It does not apply relations, because Notion replaces a relation array wholesale and accepting one back would turn a concurrent edit into silent data loss. It does not apply people either, because a people property cannot represent a Kanso user with no Notion account, so reading it back would quietly drop assignees. Relations and assignments stay Kanso-authoritative.
A page created by hand in Notion is not adopted. A ticket needs a team and a per-team number, and neither is something a page created in the mirror can supply; the poller logs it and moves on rather than inventing rows.
Kanso wins, and what that costs
If the Postgres row moved after the last successful push, an edit made in Notion is discarded and a corrective push is queued. Notion converges back on its own, which means somebody editing in Notion will see their change reverted.
That is the design, not a bug — but it has to be said out loud to the people reading the mirror, or they lose trust in the tool. Put a banner on the parent page saying the databases are maintained by Kanso. Making Notion-side editing first-class needs per-field merge and a real conflict UI; it is a v2 conversation, not a setting.
The import, which happens once
You arrive with six months of tickets already in Notion, and the import adopts them: it lists the bases it can see, you say which team each one becomes, and it copies the pages in. It reads that workspace's own columns and its own words, so a base whose status column is called État and whose options read En cours comes over as work already started rather than as a pile of Todo.
Two rules keep this exception from leaking back into the mirror. An imported ticket gets its own page in Kanso · Tickets, and the source page is never adopted and never written to. Adopting it would put “Kanso wins” in charge of a workspace somebody had just handed over, which is how an import erases the thing it imported.
A page the import cannot take — no title, or already archived — is reported with its id and its reason rather than filed as another Untitled. The once-only rule is enforced by the database, not by remembering to check, which is what makes the import safe to press twice.
Connecting it
From the setup wizard, or later from settings → setup. One step has to happen in Notion, because no provider will issue an OAuth client to a host it has never heard of: create a public integration and register the redirect URI Kanso prints next to the field.
Then paste what that page gives you — its authorization URL, or the client id and the secret together — into the client id field, and both are filled in from the one paste. Everything after that is a button. Connect Notion opens Notion's own consent screen, which is also where you pick which pages Kanso may see, so nothing has to be shared by hand from a ••• menu. Create the databases then builds the four and queues everything already in Postgres.
Set NOTION_CLIENT_ID and NOTION_CLIENT_SECRET in the environment and the paste is gone for good: every instance you bring up afterwards shows only the button. They pin the door, not the connection — nothing is mirrored until somebody consents. Anything set in the environment wins over the wizard and is shown read-only, because operator config and in-app config disagreeing silently is worse than either alone.
Pasting an internal integration token still works, folded away behind Paste an integration token instead — for an instance that already has one, and for one whose browser cannot reach a consent screen at all.
Secrets entered in the wizard are encrypted with AES-GCM before they touch the database, with a key from KANSO_SECRET_KEY or generated into the data volume. Back that key up. Without it, the stored secrets have to be entered again.
Where the mapping is lossy
Three places where Notion's model cannot hold Kanso's, stated here rather than discovered:
- people
- A
peopleproperty only accepts Notion workspace members. Kanso writes it when it knows the person's Notion id, and always writes a parallelAssignees (Kanso)rich-text with everyone's name, so the mirror stays readable. The text column is ignored on the way back. - relations
- Writing a relation replaces the whole array; there is no merge. So every push writes the entity's complete state from Postgres, which is consistent with “Kanso wins” and the reason partial pushes are never attempted.
- parent page
- The page the four databases are created under is chosen from a list rather than named by id. The list excludes any page whose parent is a database, because a search filtered to pages returns database rows — and every page the mirror writes is one.
Operating it
Two endpoints, both behind an owner or admin session:
GET /api/admin/sync
POST /api/admin/notion/reconcile
The first reports queue depth, failures and poll cursors. The second rewrites every page from Postgres, which is what to reach for after an outage or a bulk edit made in the wrong place. A full reconcile queues at most 500 tickets per call and says so in the log.
With no token at all, every write still enqueues a sync job and a no-op client completes it. The queue and the retry machinery are exercised, and nothing leaves the machine — which is also what makes Notion genuinely optional rather than nominally so.