Sangriadocs

OAuth apps

Register an app once; any workspace admin can install it via an OAuth consent screen instead of hand-creating a bot.

The integrations API covers apps a workspace admin hand-creates for their own workspace, copying a token to whatever server they run. OAuth apps are the Slack-style alternative: register an app once, and any workspace admin can click an "Add to Sangria" link and approve a consent screen to install it — no manual bot creation, no copy-pasting a token. Sangria itself is the OAuth 2.0 authorization server here (RFC 6749's authorization-code grant); your app is the client.

v1 scope

No token expiry or refresh tokens (the issued access token behaves like the long-lived bot API token it actually is — revoke it by regenerating), no partial-scope grants (an install approves every event type the app requests, or is denied entirely), no IP-based rate limiting on the token endpoint, and no uninstall webhook back to your app. All intentional v1 cuts, not gaps. PKCE is supported (see below), just optional rather than required.

Two different hosts

This matters and is easy to get wrong: /oauth/authorize is a page on the app's own site (the browser navigates there); /oauth/token is a Convex HTTP action, served from the Convex deployment's own site URL — the same host your incoming webhooks and bot API calls already use. In this self-hosted setup that's a different port (:3211 locally) or a different subdomain in production, never the app's own URL.

1. Register your app

Go to /developer/apps while signed in — this isn't workspace-scoped, so any signed-in user can register one. You'll set:

  • Redirect URIs — exact-match checked at both /oauth/authorize and /oauth/token. https:// URIs are accepted from any host; http://localhost (or 127.0.0.1/::1) is also accepted, for local development, but no other http:// host is.
  • Requested event types — the subset of message/reaction/interaction your app wants delivered to its event URL (shared across every workspace that installs your app — unlike the manually-created integrations flow, where each installation configures its own event URL).
  • Category — shown as a filter pill in the app directory (Productivity, Project Management, DevOps, Communication, Analytics, HR, or Other). Purely informational — it doesn't affect who can install the app.

You get back a client_id (public) and a client_secret, shown exactly once — only its hash is stored. Lose it and you'll need to regenerate it (which invalidates the old one immediately, but never touches any already-installed workspace's access token).

Check "List in every workspace's app directory" if you want admins to be able to discover and install your app from inside Sangria (Customize workspace → App directory), instead of only via a link on your own site. There's no review/approval step — listing is a self-service toggle, since this is a self-hosted deployment, not a public multi-tenant marketplace. The directory's "Install" button is just a shortcut: it builds the exact same /oauth/authorize URL below, using your app's first registered redirect URI.

2. Send admins to /oauth/authorize

GET https://<your-sangria-host>/oauth/authorize
  ?client_id=<your client_id>
  &redirect_uri=<one of your registered redirect URIs, URL-encoded>
  &state=<opaque value you'll verify on return>

The signed-in admin picks (or is defaulted into, if they're only an admin of one) a workspace, sees a consent screen describing what your app will be able to do, picks a channel, and clicks Allow or Deny. If you already know which workspace an admin is installing into (e.g. a link generated from inside that workspace), add &workspace_id=<id> to skip the picker — it's still re-validated against the signed-in user's actual admin workspaces server-side, so a wrong or spoofed id just falls back to the normal picker rather than being trusted outright.

  • Allow → redirected to your redirect_uri with ?code=<...>&state=<...>. The code is opaque, single-use, and expires in 10 minutes.
  • Deny → redirected with ?error=access_denied&state=<...>, no code.

If client_id is unknown/inactive, or redirect_uri doesn't exactly match a registered URI, the admin sees an error page — Sangria never redirects the browser to an unregistered destination.

PKCE (optional)

Every app here is a confidential client (it has a client_secret), so PKCE (RFC 7636) isn't required — but it's supported, and worth adding as defense-in-depth if your own "Add to Sangria" link is generated server-side:

  1. Generate a high-entropy code_verifier (43–128 characters) and keep it server-side, tied to this pending install attempt.
  2. Add &code_challenge=<BASE64URL(SHA256(code_verifier))>&code_challenge_method=S256 to the /oauth/authorize URL. Only S256 is accepted — plain is rejected outright, not silently downgraded.
  3. Include the original code_verifier (not the challenge) as a code_verifier param when you exchange the code in step 3 below.

If you don't send a code_challenge, the token exchange doesn't require a code_verifier either — this is what the in-app directory's one-click Install button does, since it constructs the /oauth/authorize URL directly from data already on file and has no server-side process of its own to hold a verifier for later.

3. Exchange the code for a token

Server-to-server, from your own backend — never the browser:

curl -X POST https://<convex-site-url>/oauth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d grant_type=authorization_code \
  -d code=<the code> \
  -d redirect_uri=<the exact same redirect_uri you sent to /oauth/authorize> \
  -d client_id=<your client_id> \
  -d client_secret=<your client_secret> \
  -d code_verifier=<omit unless you sent a code_challenge above>

Client authentication also accepts HTTP Basic (Authorization: Basic base64(client_id:client_secret)) instead of the body params — both of RFC 6749 §2.3.1's methods work, so a generic OAuth client library needs no special-casing for Sangria.

{ "access_token": "<64-hex bot API token>", "token_type": "bearer" }

The access_token is the installed app's bot API token — the exact same credential the manually-created integrations flow already uses for POST /api/messages. Use it exactly as documented on the integrations API page.

StatuserrorMeaning
200success
400invalid_requestmissing/malformed params, or not form-encoded
400unsupported_grant_typegrant_type isn't authorization_code
400invalid_grantcode expired, already used, redirect_uri doesn't match, or (if a code_challenge was sent) code_verifier is missing/wrong
401invalid_clientunknown client_id or wrong client_secret
429slow_downrate-limited

A code is single-use: exchanging it twice returns invalid_grant on the second attempt, even if the first attempt succeeded moments earlier.

Reinstalls

Installing the same app into a workspace it's already in reuses the existing bot/webhook rather than creating a duplicate — and refreshes that installation's subscribed event types to whatever your app currently requests. If you add a new requested event type after some workspaces have already installed your app, they'll pick it up the next time an admin there re-approves the consent screen, not automatically.

Install analytics

/developer/apps shows you, for each app you own: how many workspaces have it installed right now, and a 30-day chart of new installs per day. The chart is a daily rollup (an overnight cron, same pattern as the workspace analytics dashboard) — it's not live-scanning on every page load, so a fresh install shows up in the current-count immediately but the chart backfills the next day.

Slash commands

Your app can declare its own slash command (e.g. /deploy) on /developer/apps — a name, a description, and an optional usage hint. When a workspace member types it into the composer, Sangria dispatches it straight to your app instead of posting it as a message.

  • Names are globally unique across every app on this deployment (checked when you add one) and can never be one of the built-in commands (shrug, me, remind, away, active/back, status, dnd, invite, help) — this avoids the ambiguity of two different apps' identical command both ending up installed in the same workspace. Up to 10 commands per app; a name is lowercase letters/numbers/hyphens, starting with a letter.
  • Delivery reuses the exact same signed pipeline as interactive components — fire-and-forget, one delivery to the one app that registered the command, no fan-out. Unlike a button (which needs a separate event-type toggle), registering a command is itself the "deliver this to me" signal — it's delivered to your app's eventUrl as long as your installed instance has one configured (no extra subscription step needed).
  • No synchronous response. Exactly like button clicks, there's no request/response round-trip or ephemeral reply — react asynchronously via the bot API (POST /api/messages) if you want to post something back.
{
  "id": "slash_command:<workspaceId>:<memberId>:<name>:<timestamp>",
  "type": "slash_command",
  "timestamp": 1731000000000,
  "workspace": { "id": "<workspaceId>" },
  "channel": { "id": "<channelId>", "name": "general" },
  "user": { "memberId": "<memberId>", "name": "Ada" },
  "command": { "name": "deploy", "text": "production" }
}

channel is present for a channel/thread invocation; a conversation field ({ "id": "<conversationId>" }) is present instead for a DM/group-DM one. Signing and verification are identical to every other outgoing event — see Outgoing event subscriptions.

If someone types a command name nobody has registered, Sangria posts it as a normal message, same as today — nothing changes for plain text or a stray /word.

Everything else is the integrations API

Once you have an access token, posting messages, receiving signed events, and attaching interactive components all work exactly as documented for a manually-created app — an OAuth install just changes how the bot and its token came to exist.

On this page