Skip to main content
Connections are credentials a user holds for an external service (Linear, GitHub, Slack, …). Your app declares how those credentials are obtained — a connection provider — and consumes them at runtime to make authenticated calls to the third-party API. Today only OAuth 2.0 is supported. Future credential types (personal access tokens, API keys, basic auth) will plug into the same surface — apps already using defineConnectionProvider({ type: 'oauth', ... }) won’t need to migrate.
A connection provider describes the OAuth handshake your app needs. For a WORKSPACE provider (the default), a member clicks “Add connection” in your app’s settings, completes the provider’s consent screen, and a ConnectedAccount row is created in their workspace. See “Workspace or personal connections” for USER providers.A working setup needs two files — the connection provider, and a matching serverVariables declaration on defineApplication that holds the OAuth client credentials.
src/connection-providers/linear-connection.ts
src/application.config.ts
Key points:
  • name is the unique identifier string used in listConnections({ providerName }) (kebab-case, must match ^[a-z][a-z0-9-]*$).
  • displayName shows in the app’s General tab and in the AI tool list.
  • scope decides who connects: 'WORKSPACE' (the default) for connections shared with the whole workspace, 'USER' for connections each member makes for themselves. See “Workspace or personal connections” below.
  • logo is a path to an asset bundled in your app’s public/ folder, served through the app’s public assets. When omitted, the General tab falls back to a placeholder built from displayName.
  • clientIdVariable / clientSecretVariable are names, not values — they must match keys declared in defineApplication.serverVariables. The actual client_id and client_secret are entered by the server admin through the app registration UI, never committed to your repo.
  • Use serverVariables (not applicationVariables) — OAuth credentials are server-wide and one OAuth app per Twenty server.
  • Until both serverVariables are filled in, the app’s General tab shows a “needs server admin” hint and the “Add connection” button is disabled.
  • type: 'oauth' is the only supported value today. The discriminator is forward-compatible: future types ('pat', 'api-key', …) will add new sub-config blocks alongside oauth.
The OAuth callback URL your provider needs to whitelist is:
When a user clicks Reconnect, nothing stops them from authorizing a different upstream account than the one the connection was made with: Slack workspace B instead of workspace A, another GitHub organization, another Google account. Without a way to tell the two apart, Twenty would overwrite A’s tokens with B’s and everything your app built on that connection would quietly start talking to B.Set identifyAccountLogicFunction to a logic function in the same app that returns a stable id for the account behind the freshly issued token:
src/connection-providers/slack-connection.ts
When it runs. Synchronously inside the OAuth callback, right after the authorization code is exchanged for tokens and before anything is saved. The user is waiting on the redirect, so when the app is installed or synced Twenty caps the function’s timeoutSeconds at 10, whatever the manifest declares; keep it to a single request to the provider’s “who am I” endpoint. The on-connect hook, if any, still runs afterwards, once the connection is saved.Payload. The handler receives the provisional access token. It has not been persisted yet, so getConnection cannot return it; use it directly and do not log it.
Result. Return the account’s id, and optionally a handle to display:
  • externalAccountId must be stable for the life of the upstream account (a team id, an organization id, a user id), never a name the user can change. Twenty trims it and stores it on the connection on first connect.
  • handle, when returned, replaces the handle Twenty would otherwise derive (the OIDC id_token email, or the connecting user’s email). Return something a user recognizes, such as the Slack workspace name.
  • Return { externalAccountId: null } when the provider has no stable id to offer. Twenty then behaves as if no identify function were declared: no id is stored, a reconnect clears any id stored earlier, and reconnects are not checked. A bare null or a missing return is treated as an error, so a handler that forgets to return cannot silently disable the check.
  • If the function throws, times out, or returns anything else, the connection is not saved and the user lands back on the settings page with an error. Throw only when you genuinely cannot identify the account.
Reconnect refusal. On a reconnect, Twenty compares the returned externalAccountId with the one stored on the connection. If they differ, the callback is refused before anything is written: the existing tokens, handle and scope stay as they were, the on-connect hook does not run, and the user sees which account they authorized and which one the connection belongs to. Connections created before your app declared the function have no stored id yet; their next reconnect stores it.Twenty does not deduplicate on externalAccountId: connecting the same upstream account twice through Add connection creates two connections. The id is exposed on listConnections and getConnection, so an app that wants one connection per upstream account can detect duplicates itself (for instance in its on-connect hook).Writing the function. Most providers expose a single endpoint that describes the current token. For Slack that is auth.test, which returns the workspace (team_id, team) and the user behind the token:
src/logic-functions/slack-identify-account.ts
Other providers follow the same pattern, whether their “who am I” endpoint is REST or GraphQL. Throw when the provider rejects the token, and return the id as a string: convert numeric ids, such as GitHub user ids, with String(id).Identification ships with Twenty 2.48.0. If your app relies on it, declare "engines": { "twenty": ">=2.48.0" } in its package.json.
Some providers hand you data at connect time that you need to persist before the connection is usable — the classic example is Slack, where the OAuth response identifies the workspace’s team_id that inbound events will be keyed by. Set onConnectLogicFunction to reference a logic function in the same app (by its universalIdentifier), and it runs right after the ConnectedAccount is created.
src/connection-providers/slack-connection.ts
The hook runs asynchronously in the connecting workspace (it is enqueued, not awaited), so a slow or failing hook never blocks or breaks the OAuth callback — make it idempotent and handle its own retries. The handler receives:
From there use getConnection(connectedAccountId) to read the fresh access token and call the provider’s API (e.g. Slack auth.test) or persist a mapping with the key-value store.
Anything an app claims at connect time has to be released when the connection goes away. A Slack integration that claims a team_id on connect, for instance, has to release that claim so another workspace can connect the same Slack team. Set onDisconnectLogicFunction to reference a logic function in the same app, and it runs right before the ConnectedAccount and its token are deleted, so the handler can still call the provider with getConnection to release anything the on-connect hook set up remotely.
src/connection-providers/slack-connection.ts
Unlike the on-connect hook it runs inline in the disconnecting workspace, once, without retries: a failure is captured and the disconnect still completes. The handler receives the same payload shape:
The ConnectedAccount and its token are still present when the hook runs, so getConnection(connectedAccountId) resolves with a fresh access token and the handler can delete whatever it registered remotely. State the provider cannot give back (a team_id, an external subscription id) still belongs in the key-value store, written at connect time and keyed by connectedAccountId. Keep the handler short: it runs inside the disconnect request, so its timeoutSeconds is the upper bound on how long that request can take.The hook fires when a connection is removed on its own. Uninstalling the app drops its connections through a database cascade instead, so the hook does not run there. Declare an uninstallLogicFunction on defineApplication for that path: it runs before the app’s metadata is deleted, so it can still call listConnections and clean up whatever is left.
Inside a logic function handler, listConnections({ providerName }) returns this app’s ConnectedAccount rows for the given provider, with refreshed access tokens.
src/logic-functions/handlers/create-linear-issue-handler.ts
Each connection has:Key points:
  • Pass { providerName } to filter by provider; omit it to get all connections this app owns across all providers.
  • The server transparently refreshes the access token before returning. Your handler always sees a usable token (or authFailedAt set).
  • getConnection(id) is the single-row equivalent.
The platform sets authFailedAt on its own only when an OAuth refresh fails. Providers whose tokens are never refreshed (a Slack bot token, for example) fail only at call time inside your handlers, so the connection row in settings would keep showing Connected while every call dies. When your code hits a definitive auth rejection, report it:
This sets authFailedAt and the optional reason on the connection. The settings row flips to Reconnect needed with a Reconnect button, the connection detail page shows the reason, and getConnection starts throwing AppConnectionAuthFailedError for that row. The flag and reason clear automatically when the user reconnects. A reason longer than 1000 characters is rejected, so truncate provider payloads before passing them.Only report failures that a reconnect would actually fix (an invalid_auth-class rejection from the provider), never transient network errors. An app can only report its own connections, and a request user can only report their own personal credentials.The mutation behind this helper ships with Twenty 2.40.0, so calling it against an older server fails. If your app uses it, declare the dependency in your package.json: "engines": { "twenty": ">=2.40.0" }.
Each connection has one of two scopes:
  • Workspace shared ('WORKSPACE') — any workspace member can use the credential. Cron / database triggers also see it, since they have no request user.
  • Just for me ('USER') — the credential is private to the connecting member. Any logic function called on their behalf (HTTP-route trigger with isAuthRequired: true) sees it; cron triggers and database events do not.
The provider’s scope decides which one its new connections get:
  • scope: 'WORKSPACE' (the default) — the app works with connections shared with the workspace. Add connection in the app’s settings creates a workspace-shared connection, and a member can turn a personal connection made earlier (before Twenty 2.49.0, or while the provider was USER) into a shared one with Share with workspace.
  • scope: 'USER' — each member connects their own account, creating personal connections. Members will connect from their App preferences, which is not available yet: until then a USER provider has no Add connection button, and the app’s settings only list the connections a member can already see.
A provider has exactly one scope. If your app needs both a shared credential and one per member for the same service (a bot token and each member’s own account, say), declare two providers.Twenty refuses a new connection whose scope does not match the provider’s, including when your app changes its scope while a member is on the provider’s consent screen. Changing scope in a later version does not move or delete existing connections: they keep their scope and can still be disconnected or reconnected.Adding or reconnecting a Just for me connection requires CONNECTED_ACCOUNTS permission, and adding or reconnecting a Workspace shared one requires APPLICATIONS permission. Reconnecting a Workspace shared connection as Just for me requires both.Use the right connection for each handler:
Multiple connections per (user, provider) are allowed, so the same user can hold “Personal Linear” and “Work Linear” side by side.Provider scope ships with Twenty 2.49.0. If your app declares scope, declare "engines": { "twenty": ">=2.49.0" } in its package.json. In the same version a connection’s scope replaces its visibility, which stays available as a deprecated lowercase alias. listConnections and getConnection from twenty-sdk 2.49.0 read scope, so apps built with it need a 2.49.0 server.
For each connection provider, the server admin needs to register an OAuth app at the third party first.
  1. Go to the provider’s developer settings (e.g. https://linear.app/settings/api/applications/new).
  2. Set the Redirect URI to <SERVER_URL>/auth/apps/callback.
  3. Copy the generated Client ID and Client Secret.
  4. Open the installed app in Twenty as a server admin → set the values on the corresponding serverVariables.
  5. Workspace members can then add connections to WORKSPACE providers from the per-app Connections section.