twentyhq/twenty · error · Error

Token exchange failed: ${response.status} ${response.statusT

Error message

Token exchange failed: ${response.status} ${response.statusText}

What it means

Thrown by exchangeCredentialsForTokens when the OAuth token endpoint returns a non-2xx HTTP status. The function POSTs client_credentials grant type to {apiUrl}/oauth/token, and any non-ok response is treated as a token exchange failure. The HTTP status code and status text are included for diagnostics.

Source

Thrown at packages/twenty-sdk/src/cli/utilities/auth/exchange-credentials-for-tokens.ts:20

export const exchangeCredentialsForTokens = async (
  configService: ConfigService,
  params: { clientId: string; clientSecret: string },
): Promise<{ accessToken: string; refreshToken?: string }> => {
  const config = await configService.getConfig();

  const response = await fetch(`${config.apiUrl}/oauth/token`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      grant_type: 'client_credentials',
      client_id: params.clientId,
      client_secret: params.clientSecret,
    }),
  });

  if (!response.ok) {
    throw new Error(
      `Token exchange failed: ${response.status} ${response.statusText}`,
    );
  }

  const data = (await response.json()) as {
    access_token: string;
    refresh_token?: string;
  };

  await configService.setConfig({
    appAccessToken: data.access_token,
    ...(data.refresh_token ? { appRefreshToken: data.refresh_token } : {}),
  });

  return {
    accessToken: data.access_token,
    refreshToken: data.refresh_token,
  };

View on GitHub (pinned to 1f5dd2bbd2)

Solutions

  1. If status is 401: re-run the SDK registration command to get fresh clientId/clientSecret credentials.
  2. If status is 400: verify the apiUrl in config is correct and points to the right Twenty instance.
  3. If status is 500/502/503: the server is likely down or overloaded — retry after a brief wait.
  4. Check that the client_secret was copied completely (no truncation) and has no leading/trailing whitespace.
  5. Ensure the apiUrl uses the correct protocol and path (https://, no trailing slash issues).

Example fix

// before
const { accessToken } = await exchangeCredentialsForTokens(configService, {
  clientId,
  clientSecret,
});

// after — handle specific HTTP statuses
try {
  const { accessToken } = await exchangeCredentialsForTokens(configService, {
    clientId, clientSecret,
  });
} catch (e) {
  if (e.message.includes('401')) {
    console.error('Invalid credentials. Re-run registration to get a new client secret.');
  }
  throw e;
}
Defensive patterns

Strategy: try-catch

Try / catch

try {
  const { accessToken } = await exchangeCredentialsForTokens(configService, { clientId, clientSecret });
} catch (error) {
  if (error instanceof Error && error.message.startsWith('Token exchange failed')) {
    const status = error.message.match(/HTTP (\d+)/)?.[1];
    if (status === '401') {
      console.error('Invalid client credentials. Re-run registration to obtain new secrets.');
    } else if (status?.startsWith('5')) {
      console.error('Server error. Retry in a moment.');
    }
    process.exit(1);
  }
  throw error;
}

Prevention

When it happens

Trigger: The OAuth /oauth/token endpoint returns 401 (invalid client_id/client_secret), 400 (malformed request), 403 (forbidden), 500 (server error), or any other non-2xx status. This happens during the SDK CLI authentication flow when exchanging app registration credentials for access tokens.

Common situations: The client_secret is wrong, expired, or was regenerated since it was stored. The client_id doesn't match a valid app registration. The apiUrl is wrong (pointing to a different environment). The OAuth server is temporarily down. The client_credentials grant is not enabled for this app registration.

Related errors


AI-assisted analysis of twentyhq/twenty@1f5dd2bbd2 (2026-08-12). Data as JSON: /api/errors/ce8df653b24d4c78. Report an issue: GitHub.