| title | Connections |
|---|---|
| description | Let your app act on a user's behalf in third-party services via OAuth. |
| icon | plug |
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. The user clicks "Add connection" in your app's settings, completes the provider's consent screen, and a ConnectedAccount row is created in their workspace.
A working setup needs two files — the connection provider, and a matching serverVariables declaration on defineApplication that holds the OAuth client credentials.
import { defineConnectionProvider } from 'twenty-sdk/define';
export default defineConnectionProvider({
universalIdentifier: '9c7d1f5e-6a0b-4d44-be0c-3f8b5a9d4e6f',
name: 'linear',
displayName: 'Linear',
icon: 'IconBrandLinear',
type: 'oauth',
oauth: {
authorizationEndpoint: 'https://linear.app/oauth/authorize',
tokenEndpoint: 'https://api.linear.app/oauth/token',
scopes: ['read', 'write'],
// These must match keys in `defineApplication.serverVariables` below.
clientIdVariable: 'LINEAR_CLIENT_ID',
clientSecretVariable: 'LINEAR_CLIENT_SECRET',
// Optional: defaults to 'json'. Some providers (Linear, Slack) want
// 'form-urlencoded' for the token request.
tokenRequestContentType: 'form-urlencoded',
// Optional: defaults to true. Disable only if the provider rejects PKCE.
usePkce: false,
// Optional: extra query params on the authorize URL.
// authorizationParams: { prompt: 'consent' },
// Optional: provider's RFC 7009 token revocation endpoint, called on disconnect.
// revokeEndpoint: 'https://example.com/oauth/revoke',
},
// Optional: a logic function in this app to run right after a connection is
// established. See "Run a logic function on connect".
// onConnectLogicFunction: { universalIdentifier: '3a2b1c0d-...-...' },
// Optional: a logic function in this app to run right after a connection is
// removed. See "Run a logic function on disconnect".
// onDisconnectLogicFunction: { universalIdentifier: '4d5e6f70-...-...' },
});import { defineApplication } from 'twenty-sdk/define';
export default defineApplication({
universalIdentifier: '...',
displayName: 'Linear',
description: 'Connect Linear to Twenty.',
// OAuth client credentials live on the app registration (one OAuth app per
// Twenty server, configured by the admin) — not per-workspace. Declare them
// as serverVariables so the admin can fill them in once for all installs.
serverVariables: {
LINEAR_CLIENT_ID: {
description: 'OAuth client ID from your Linear OAuth application.',
isSecret: false,
isRequired: true,
},
LINEAR_CLIENT_SECRET: {
description: 'OAuth client secret from your Linear OAuth application.',
isSecret: true,
isRequired: true,
},
},
});Key points:
nameis the unique identifier string used inlistConnections({ providerName })(kebab-case, must match^[a-z][a-z0-9-]*$).displayNameshows in the per-app settings tab and in the AI tool list.clientIdVariable/clientSecretVariableare names, not values — they must match keys declared indefineApplication.serverVariables. The actualclient_idandclient_secretare entered by the server admin through the app registration UI, never committed to your repo.- Use
serverVariables(notapplicationVariables) — OAuth credentials are server-wide and one OAuth app per Twenty server. - Until both
serverVariablesare filled in, the per-app settings 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 alongsideoauth.
The OAuth callback URL your provider needs to whitelist is:
https://<your-twenty-server>/auth/apps/callback
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.
export default defineConnectionProvider({
universalIdentifier: '...',
name: 'slack',
displayName: 'Slack',
type: 'oauth',
oauth: {
/* ... */
},
// Runs claimSlackTeam after every successful Slack connection.
onConnectLogicFunction: {
universalIdentifier: '3a2b1c0d-1111-4222-8333-444455556666',
},
});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:
type OnConnectPayload = {
connectionProviderId: string;
connectionProviderName: string; // e.g. 'slack'
connectedAccountId: string;
};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.
export default defineConnectionProvider({
universalIdentifier: '...',
name: 'slack',
displayName: 'Slack',
type: 'oauth',
oauth: {
/* ... */
},
// Runs releaseSlackTeam after every Slack disconnection.
onDisconnectLogicFunction: {
universalIdentifier: '4470aba8-5ff5-4800-88db-2a427cd8677c',
},
});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:
type OnDisconnectPayload = {
connectionProviderId: string;
connectionProviderName: string; // e.g. 'slack'
connectedAccountId: string;
};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.
import { listConnections } from 'twenty-sdk/logic-function';
export const createLinearIssueHandler = async (input: {
teamId?: string;
title?: string;
}) => {
if (!input.teamId || !input.title) {
return { success: false, error: 'teamId and title are required' };
}
const connections = await listConnections({ providerName: 'linear' });
// Workspace-shared credentials win when present; fall back to the first
// user-visibility one. For HTTP-route triggers you typically pick the
// request user's connection via event.userWorkspaceId instead.
const connection =
connections.find((c) => c.visibility === 'workspace') ?? connections[0];
if (!connection) {
return {
success: false,
error:
'Linear is not connected. Open the app settings and click "Add connection".',
};
}
// Use connection.accessToken to call the third-party API.
const response = await fetch('https://api.linear.app/graphql', {
method: 'POST',
headers: {
Authorization: `Bearer ${connection.accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: `mutation { issueCreate(input: { teamId: "${input.teamId}", title: "${input.title}" }) { success } }`,
}),
});
return { success: response.ok };
};Each connection has:
| Field | Description |
|---|---|
id |
Unique row id; pass to getConnection(id) to refetch a single one |
visibility |
'user' (private to one workspace member) or 'workspace' (shared with all members) |
scopes |
OAuth permissions granted by the upstream provider (distinct from visibility — those are unrelated) |
userWorkspaceId |
The owner's userWorkspace id — useful for picking "the request user's connection" in HTTP-route triggers |
workspaceMemberId |
The owner's workspace member id, or null if they've since left the workspace |
accessToken |
Fresh OAuth access token (refreshed automatically if expired) |
name |
The connection's display name (auto-derived at OAuth callback, user-renameable) |
handle |
The connected upstream account's email, refreshed on each reconnect; resolved from the OIDC id_token returned at token exchange, falling back to the connecting Twenty user's email when the provider doesn't return one |
authFailedAt |
Set when the most recent refresh failed, or when the app reported the credential dead via reportConnectionAuthFailure; the user must reconnect |
authFailedReason |
Human-readable explanation for authFailedAt, when the reporter gave one; shown on the connection row in settings next to the Reconnect button |
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
authFailedAtset). 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:
import { reportConnectionAuthFailure } from 'twenty-sdk/logic-function';
await reportConnectionAuthFailure({
connectionId: connection.id,
reason:
'Slack rejected the stored token (invalid_auth). Reconnect to restore the integration.',
});This sets authFailedAt (and the optional reason, capped at 1000 characters) on the connection. The settings row flips to Reconnect needed with a Reconnect button, the detail page shows the reason, and getConnection starts throwing AppConnectionAuthFailedError for that row. The flag and reason clear automatically when the user reconnects.
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 user-visibility credentials.
When a user clicks "Add connection," they're prompted to pick a visibility:
- Just for me — the credential is private to the connecting user. Any logic function called on their behalf (HTTP-route trigger with
isAuthRequired: true) sees it; cron triggers and database events do not. - Workspace shared — any workspace member can use the credential. Cron / database triggers also see it, since they have no request user.
Use the right one for each handler:
// HTTP-route trigger — prefer the request user's own connection.
const conn =
connections.find((c) => c.userWorkspaceId === event.userWorkspaceId) ??
connections.find((c) => c.visibility === 'workspace');
// Cron trigger — no request user; only shared credentials are sensible.
const conn = connections.find((c) => c.visibility === 'workspace');Multiple connections per (user, provider) are allowed, so the same user can hold "Personal Linear" and "Work Linear" side by side.
For each connection provider, the server admin needs to register an OAuth app at the third party first.
- Go to the provider's developer settings (e.g. https://linear.app/settings/api/applications/new).
- Set the Redirect URI to
<SERVER_URL>/auth/apps/callback. - Copy the generated Client ID and Client Secret.
- Open the installed app in Twenty as a server admin → set the values on the corresponding
serverVariables. - Workspace members can then add connections from the per-app Connections section.