Skip to main content

Overview

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. OAuth 2.0 is built in. Services with their own connection flow — a hosted login page, an account id delivered by webhook — use a custom provider, where your app drives the flow and Twenty keeps the user, the workspace and the saved connection. 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 is 'oauth' (with an oauth block) or 'custom' (with a custom block, see “Custom connection flows”). The discriminator is forward-compatible: future types ('pat', 'api-key', …) will add new sub-config blocks alongside these.
The OAuth callback URL your provider needs to whitelist is:
Some services connect accounts through their own flow rather than OAuth: Unipile, Plaid or Merge send the user to a hosted page and then report the connected account to a URL you gave them, sometimes from their servers rather than the browser. Declare these with type: 'custom' and two logic functions:
src/connection-providers/linkedin-connection.ts
Add connection and Reconnect work as for OAuth. Twenty checks the member may connect, signs a short-lived state for the attempt, and runs authorizeLogicFunction with a callbackUrl that carries it. Return where to send the user:
src/logic-functions/handlers/linkedin-authorize-handler.ts
Hand callbackUrl to the service unchanged. Twenty accepts it as a browser redirect (GET) and as a server notification (POST), and runs callbackLogicFunction for each request with its method, query parameters (without state), headers, parsed body and rawBody (the exact request bytes, null on GET). Return the account the request proves, or null when it carries none — typically the browser coming back while the account id travels by notification:
src/logic-functions/handlers/linkedin-callback-handler.ts
The signed state in callbackUrl only proves the request carries that URL, not that the service sent it. When the service signs its notifications, verify the signature over rawBody before trusting the body. Headers are lowercased, and cookie, authorization and proxy-authorization are never passed on:
src/logic-functions/handlers/hosted-callback-handler.ts
The callback returns:Both functions run while the member waits on a redirect, so their timeout is capped at 10 seconds. A failure or an invalid return aborts the connection: nothing is saved and onConnectLogicFunction does not run. onConnectLogicFunction runs when a connection is created, reconnected or restored, not again when the service reports an account that is already connected. The state expires after an hour, so a notification arriving later is refused. Services commonly retry notifications: a repeat for an account already connected updates that connection, so returning the same account again is safe.Twenty does not refresh or revoke custom connections. Release what the service holds for the account in onDisconnectLogicFunction.A provider’s type, like its scope, cannot change once it is synced: to move a service between oauth and custom, declare a new provider with a new universalIdentifier.Custom providers ship with Twenty 2.52.0.
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 when the connection is disconnected and again when it is deleted.
src/connection-providers/slack-connection.ts
Unlike the on-connect hook it runs inline in the disconnecting workspace, without retries: a failure is captured and the disconnect or delete still completes. The handler receives the same payload shape:
On a disconnect the ConnectedAccount and its token are still present, so getConnection(connectedAccountId) resolves and the handler can release whatever it registered remotely. On a delete the credentials may already be gone (a member can delete a connection they disconnected earlier), so make the handler idempotent and keep the state it needs (a team_id, an external subscription id) in the key-value store, written at connect time and keyed by connectedAccountId. Keep it short: it runs inside the request, so its timeoutSeconds is the upper bound on how long that request can take.The hook fires when a connection is disconnected or 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 belongs to the connecting member. In runs made on a member’s behalf (HTTP-route trigger with isAuthRequired: true), listConnections returns the shared connections plus that member’s own, and findConnectionForRequest picks the member’s personal connection, falling back to a shared one when they have none. In runs without a request user (cron triggers, for example), listConnections returns every connection, including every member’s personal ones.
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 workspace settings creates a workspace-shared connection.
  • scope: 'USER' — each member connects their own account, creating personal connections. Members add, reconnect and remove them from the app’s page under Settings > User > Apps preferences, which requires the User Apps permission. The app’s workspace settings have no Add connection button for a USER provider.
Apps preferences is not enabled in every workspace yet: where it is not, members have no place to add a USER connection, so do not rely on one for a feature every workspace needs now.
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.A connection always has its provider’s scope, and a provider’s scope is part of its identity: a later version cannot change the scope of an existing provider, and the upgrade is refused if it tries. To move a service from one scope to the other, declare a new provider with a new universalIdentifier and drop the old one. Upgrading then deletes the old provider and every connection made with it through the regular disconnect path (onDisconnectLogicFunction, token revocation, channel cleanup), and members connect again through the new provider.Adding or reconnecting a Just for me connection requires the User Apps permission, and adding or reconnecting a Workspace shared one requires the Workspace Apps permission.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.