Build a connector for the marketplace
Write a service that signs in with Spunto CMS, receives a workspace's webhooks and gets listed in the marketplace — OAuth with PKCE, owned webhooks, signature checks.
A connector is your service, at your address, that a workspace admin installs from the marketplace. Slack and Google Drive are built exactly this way, with nothing a third party couldn't use. This page walks the same path.
What you end up with: a "Sign in with Spunto CMS" button, a token scoped to one workspace, a webhook that is yours alone, and a listing in Settings → Marketplace of every space on the instance.
1. Register the app
Open Developers (/app/developpeurs, linked at the bottom of your spaces)
and register an app:
- Redirect URI — where consent sends the browser back, e.g.
https://my-connector.example/oauth/callback. Compared exactly, never by prefix. HTTPS, except onlocalhost. - Scopes — what the app may ask for. The admin who installs it can grant less, never more.
The client_secret is shown once. Keep it on your server.
Ask for what you need and nothing else. A connector that posts to a chat needs
items:read, schema:read (list and status names), events:read,
webhooks:read and webhooks:write — and the viewer role, since it writes
nothing.
2. Start the flow from your install URL
The marketplace's Install button is a plain link to your install URL,
with ?workspace_id=… appended: the space the person came from. You start
OAuth there, because only you can keep the PKCE verifier until the code comes
back.
const verifier = base64url(randomBytes(32))
const challenge = base64url(sha256(verifier))
const state = base64url(randomBytes(16))
// Keep { state, verifier } in a signed, httpOnly cookie for ~10 minutes.
const url = new URL("https://cms.spunto.net/install")
url.searchParams.set("client_id", CLIENT_ID)
url.searchParams.set("redirect_uri", "https://my-connector.example/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") // a hint
if (workspaceId) url.searchParams.set("workspace_id", workspaceId) // a hint
redirect(url)role and workspace_id are hints: the consent screen preselects them, the
admin chooses, and the API caps the role at the admin's own.
3. Exchange the code
Check state against your cookie before anything else, then trade the 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://my-connector.example/oauth/callback",
},
})
// data.accessToken (spa_…), data.workspaceId, data.installationId, data.scope, data.roleThe token never expires; its lifetime is the installation. Store it encrypted. Signing in again later runs the same flow: the API renews the token on the same installation.
4. Plug your webhook
const api = createSpuntoCmsClient({ baseUrl: API, token: accessToken })
const { data: hook } = await api.POST("/workspaces/{workspaceId}/webhooks", {
params: { path: { workspaceId } },
body: { name: "My connector", url: "https://my-connector.example/webhooks/spunto", eventTypes: ["item.*"] },
})
// hook.secret is returned once — store it encrypted, per workspace.An app does not need the admin role for this, but it needs events:read on top
of webhooks:write: a webhook is the event log, pushed. The endpoint belongs
to your installation — you only see your own, nobody else's app can touch it,
and it is deleted when the space uninstalls you.
5. Receive deliveries
import { SIGNATURE_HEADER, verifyWebhookSignature, type WebhookPayload } from "@spunto-cms/client"
const raw = await request.text() // the RAW body
const payload = JSON.parse(raw) as WebhookPayload
const secret = await secretFor(payload.workspaceId) // the body only tells you WHICH secret
if (!(await verifyWebhookSignature(secret, raw, request.headers.get(SIGNATURE_HEADER)))) {
return new Response("bad signature", { status: 401 })
}
// Idempotent on payload.event.id (or batch.batchId), answer 2xx fast.Three habits make a connector bearable:
- skip events whose
causeis"import"— an import is not news; - a
kind: "batch"delivery is one bulk edit: make it one message; - if your provider fails (a deleted Slack channel), record it and still answer 200. Answering 5xx makes the API retry, then pause your endpoint for the whole space.
When a call answers 401, the space uninstalled you. Stop, keep the settings, and let a new sign-in reinstall.
6. Publish
Back in Developers, fill the listing — address, tagline, category, publisher, install URL; description and icon are optional — and press Publish. If something is missing, the error says what.
The Verified badge is set by whoever operates the instance, not by you: the publisher name is free text, and the badge is what tells "Slack by Spunto" from an impostor. Unverified listings say so, and remind people to check where the install button leads.