packages/twenty-docs/developers/extend/apps/logic/connections.mdx
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',
},
});
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:
name is the unique identifier string used in listConnections({ providerName }) (kebab-case, must match ^[a-z][a-z0-9-]*$).displayName shows in the per-app settings tab and in the AI tool list.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.serverVariables (not applicationVariables) — OAuth credentials are server-wide and one OAuth app per Twenty server.serverVariables are 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 alongside oauth.The OAuth callback URL your provider needs to whitelist is:
https://<your-twenty-server>/apps/oauth/callback
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 |
accessToken | Fresh OAuth access token (refreshed automatically if expired) |
name / handle | The connection's display name (auto-derived at OAuth callback, user-renameable) |
authFailedAt | Set when the most recent refresh failed; the user must reconnect |
Key points:
{ providerName } to filter by provider; omit it to get all connections this app owns across all providers.authFailedAt set).getConnection(id) is the single-row equivalent.When a user clicks "Add connection," they're prompted to pick a visibility:
isAuthRequired: true) sees it; cron triggers and database events do not.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.
</Accordion> <Accordion title="One-time provider setup" description="Register your OAuth app with the third-party service">For each connection provider, the server admin needs to register an OAuth app at the third party first.
<SERVER_URL>/apps/oauth/callback.serverVariables.