Skip to content

SuccessFactors

The SuccessFactors class is the client for the SAP SuccessFactors OData API.

successfactors

SuccessFactors Module to interact with the SuccessFactors API.

See: https://community.sap.com/t5/enterprise-resource-planning-blogs-by-members/how-to-initiate-an-oauth-connection-to-successfactors-employee-central/ba-p/13332388 https://help.sap.com/docs/SAP_SUCCESSFACTORS_PLATFORM/d599f15995d348a1b45ba5603e2aba9b/78b1d8aac783455684a7de7a8a5b0c04.html

Classes:

Name Description
SuccessFactors

Class SuccessFactors is used to retrieve and automate settings in SuccessFactors.

SuccessFactors

SuccessFactors(
    base_url: str,
    as_url: str,
    client_id: str,
    client_secret: str,
    username: str = "",
    password: str = "",
    company_id: str = "",
    authorization_url: str = "",
    logger: Logger = default_logger,
)

Class SuccessFactors is used to retrieve and automate settings in SuccessFactors.

Initialize the SuccessFactors object.

Parameters:

Name Type Description Default
base_url str

The base URL of the SuccessFactors tenant.

required
as_url str

The Application Service URL of the SuccessFactors tenant.

required
client_id str

The SuccessFactors Client ID.

required
client_secret str

The SuccessFactors Client Secret.

required
username str

The user name in SuccessFactors.

''
password str

The password of the SuccessFactors user.

''
company_id str

The SuccessFactors company ID.

''
authorization_url str

URL for SuccessFactors login. If not given it will be constructed with default values using base_url.

''
logger Logger

The logging object to use for all log messages. Defaults to default_logger.

default_logger

Methods:

Name Description
config

Return the configuration dictionary.

credentials

Return the login credentials.

idp_data

Return the IDP data used to request the SAML assertion.

request_header

Return the request header used for Application calls.

do_request

Execute an HTTP request with retries for transient failures.

parse_request_response

Convert the request response (JSON) to a Python dict in a safe way.

exist_result_item

Check existence of key / value pair in the response properties of a SuccessFactors API call.

get_result_value

Get value of a result property with a given key of a SuccessFactors API call.

get_saml_assertion

Get SAML Assertion for SuccessFactors authentication.

authenticate

Authenticate at SuccessFactors with client ID and client secret.

get_country

Get information for a country / countries.

get_user

Get information for a SuccessFactors user account.

get_user_account

Get information for a SuccessFactors User Account.

update_user

Update user data. E.g. update the user password or email.

get_employee

Get a list of employee(s) matching given criteria.

get_entities_metadata

Get the schema (metadata) for a list of entities (list can be empty to get it for all).

get_entity_metadata

Get the schema (metadata) for an entity.

update_user_email

Update user email.

Source code in packages/pyxecm/src/pyxecm_customizer/successfactors.py
def __init__(
    self,
    base_url: str,
    as_url: str,
    client_id: str,
    client_secret: str,
    username: str = "",
    password: str = "",
    company_id: str = "",
    authorization_url: str = "",
    logger: logging.Logger = default_logger,
) -> None:
    """Initialize the SuccessFactors object.

    Args:
        base_url (str):
            The base URL of the SuccessFactors tenant.
        as_url (str):
            The Application Service URL of the SuccessFactors tenant.
        client_id (str):
            The SuccessFactors Client ID.
        client_secret (str):
            The SuccessFactors Client Secret.
        username (str, optional):
            The user name in SuccessFactors.
        password (str, optional):
            The password of the SuccessFactors user.
        company_id (str, optional):
            The SuccessFactors company ID.
        authorization_url (str, optional):
            URL for SuccessFactors login.
            If not given it will be constructed with default values using base_url.
        logger (logging.Logger, optional):
            The logging object to use for all log messages. Defaults to default_logger.

    """
    if logger != default_logger:
        self.logger = logger.getChild("successfactors")
        for logfilter in logger.filters:
            self.logger.addFilter(logfilter)

    successfactors_config = {}

    # this class assumes that the base URL is provided without
    # a trailing "/". Otherwise the trailing slash is removed.
    base_url = base_url.removesuffix("/")

    # Set the authentication endpoints and credentials
    successfactors_config["baseUrl"] = base_url
    successfactors_config["asUrl"] = as_url
    successfactors_config["clientId"] = client_id
    successfactors_config["clientSecret"] = client_secret
    successfactors_config["username"] = username.split("@", maxsplit=1)[
        0
    ]  # we don't want the company ID in the user name
    successfactors_config["password"] = password
    if company_id:
        successfactors_config["companyId"] = company_id
    elif "@" in username:
        # if the company ID is not provided as a parameter
        # we check if it is included in the username:
        company_id = username.split("@")[1]
        successfactors_config["companyId"] = company_id
    if authorization_url:
        successfactors_config["authenticationUrl"] = authorization_url
    else:
        successfactors_config["authenticationUrl"] = successfactors_config["baseUrl"] + "/oauth/token"

    successfactors_config["idpUrl"] = successfactors_config["baseUrl"] + "/oauth/idp"

    if not username:
        # Set the data for the token request
        successfactors_config["authenticationData"] = {
            "grant_type": "client_credentials",
            "client_id": client_id,
            "client_secret": client_secret,
        }
    else:
        # Set the data for the token request
        successfactors_config["authenticationData"] = {
            "grant_type": "urn:ietf:params:oauth:grant-type:saml2-bearer",
            "company_id": successfactors_config["companyId"],
            "username": successfactors_config["username"],
            "password": password,
            "client_id": client_id,
            "client_secret": client_secret,
        }

    successfactors_config["idpData"] = {
        "client_id": client_id,
        "user_id": successfactors_config["username"],
        "token_url": successfactors_config["authenticationUrl"],
        "private_key": client_secret,
    }

    self._config = successfactors_config
    self._access_token = None
    self._assertion = None

config

config() -> dict

Return the configuration dictionary.

Returns:

Name Type Description
dict dict

The configuration dictionary.

Source code in packages/pyxecm/src/pyxecm_customizer/successfactors.py
def config(self) -> dict:
    """Return the configuration dictionary.

    Returns:
        dict:
            The configuration dictionary.

    """
    return self._config

credentials

credentials() -> dict

Return the login credentials.

Returns:

Name Type Description
dict dict

A dictionary with login credentials for SuccessFactors.

Source code in packages/pyxecm/src/pyxecm_customizer/successfactors.py
def credentials(self) -> dict:
    """Return the login credentials.

    Returns:
        dict:
            A dictionary with login credentials for SuccessFactors.

    """
    return self.config()["authenticationData"]

idp_data

idp_data() -> dict

Return the IDP data used to request the SAML assertion.

Returns:

Name Type Description
dict dict

A dictionary with IDP data for SuccessFactors.

Source code in packages/pyxecm/src/pyxecm_customizer/successfactors.py
def idp_data(self) -> dict:
    """Return the IDP data used to request the SAML assertion.

    Returns:
        dict:
            A dictionary with IDP data for SuccessFactors.

    """
    return self.config()["idpData"]

request_header

request_header(
    content_type: str = "application/json",
) -> dict

Return the request header used for Application calls.

Consists of Bearer access token and Content Type

Parameters:

Name Type Description Default
content_type str

The content type for the request. Defaults to "application/json".

'application/json'

Returns:

Name Type Description
dict dict

The request header values.

Source code in packages/pyxecm/src/pyxecm_customizer/successfactors.py
def request_header(self, content_type: str = "application/json") -> dict:
    """Return the request header used for Application calls.

    Consists of Bearer access token and Content Type

    Args:
        content_type (str, optional):
            The content type for the request. Defaults to "application/json".

    Returns:
        dict:
            The request header values.

    """

    request_header = {
        "Authorization": "Bearer {}".format(self._access_token),
        "Content-Type": content_type,
        "Accept": content_type,
    }
    return request_header

do_request

do_request(
    url: str,
    method: str = "GET",
    headers: dict | None = None,
    data: dict | None = None,
    json_data: dict | None = None,
    timeout: float = REQUEST_TIMEOUT,
    show_error: bool = True,
    show_warning: bool = False,
    warning_message: str = "",
    failure_message: str = "",
    success_message: str = "",
    max_retries: int = REQUEST_MAX_RETRIES,
    retry_delay: float = REQUEST_RETRY_DELAY,
    retry_status_codes: tuple[
        int, ...
    ] = REQUEST_RETRY_STATUS_CODES,
    parse_request_response: bool = True,
) -> dict | Response | None

Execute an HTTP request with retries for transient failures.

Returns parsed JSON by default. Returns the raw response only when parse_request_response is False.

Parameters:

Name Type Description Default
url str

The URL to send the request to.

required
method str

The HTTP method (GET, POST, etc.). Defaults to "GET".

'GET'
headers dict | None

The request headers. Defaults to None.

None
data dict | None

The request payload sent as form data. Defaults to None.

None
json_data dict | None

The request payload sent as JSON. Defaults to None.

None
timeout float

The timeout for the request in seconds. Defaults to REQUEST_TIMEOUT.

REQUEST_TIMEOUT
show_error bool

Whether to log an error if the request fails. If False, a warning is logged only if show_warning is True. Defaults to True.

True
show_warning bool

Whether to log a warning if the request fails (used if show_error is False or for timeouts and connection errors). Defaults to False.

False
warning_message str

Specific warning message. If not given the failure_message is used.

''
failure_message str

Description of the failed action, used in log messages.

''
success_message str

Message that is logged if the request succeeds.

''
max_retries int

The maximum number of retries. Defaults to REQUEST_MAX_RETRIES.

REQUEST_MAX_RETRIES
retry_delay float

The base delay in seconds between retries. The delay is multiplied by the number of the retry. Defaults to REQUEST_RETRY_DELAY.

REQUEST_RETRY_DELAY
retry_status_codes tuple[int, ...]

The HTTP status codes that trigger a retry. Defaults to REQUEST_RETRY_STATUS_CODES.

REQUEST_RETRY_STATUS_CODES
parse_request_response bool

Whether the response should be parsed as JSON into a dictionary. If False, the raw response is returned. Defaults to True.

True

Returns:

Type Description
dict | Response | None

dict | requests.Response | None: The parsed response, or the raw response if parse_request_response is False. None in case of an error.

Source code in packages/pyxecm/src/pyxecm_customizer/successfactors.py
def do_request(
    self,
    url: str,
    method: str = "GET",
    headers: dict | None = None,
    data: dict | None = None,
    json_data: dict | None = None,
    timeout: float = REQUEST_TIMEOUT,
    show_error: bool = True,
    show_warning: bool = False,
    warning_message: str = "",
    failure_message: str = "",
    success_message: str = "",
    max_retries: int = REQUEST_MAX_RETRIES,
    retry_delay: float = REQUEST_RETRY_DELAY,
    retry_status_codes: tuple[int, ...] = REQUEST_RETRY_STATUS_CODES,
    parse_request_response: bool = True,
) -> dict | requests.Response | None:
    """Execute an HTTP request with retries for transient failures.

    Returns parsed JSON by default. Returns the raw response only when
    `parse_request_response` is False.

    Args:
        url (str):
            The URL to send the request to.
        method (str, optional):
            The HTTP method (GET, POST, etc.). Defaults to "GET".
        headers (dict | None, optional):
            The request headers. Defaults to None.
        data (dict | None, optional):
            The request payload sent as form data. Defaults to None.
        json_data (dict | None, optional):
            The request payload sent as JSON. Defaults to None.
        timeout (float, optional):
            The timeout for the request in seconds. Defaults to REQUEST_TIMEOUT.
        show_error (bool, optional):
            Whether to log an error if the request fails. If False, a warning is logged
            only if show_warning is True. Defaults to True.
        show_warning (bool, optional):
            Whether to log a warning if the request fails (used if show_error is False
            or for timeouts and connection errors). Defaults to False.
        warning_message (str, optional):
            Specific warning message. If not given the failure_message is used.
        failure_message (str, optional):
            Description of the failed action, used in log messages.
        success_message (str, optional):
            Message that is logged if the request succeeds.
        max_retries (int, optional):
            The maximum number of retries. Defaults to REQUEST_MAX_RETRIES.
        retry_delay (float, optional):
            The base delay in seconds between retries. The delay is multiplied
            by the number of the retry. Defaults to REQUEST_RETRY_DELAY.
        retry_status_codes (tuple[int, ...], optional):
            The HTTP status codes that trigger a retry. Defaults to REQUEST_RETRY_STATUS_CODES.
        parse_request_response (bool, optional):
            Whether the response should be parsed as JSON into a dictionary.
            If False, the raw response is returned. Defaults to True.

    Returns:
        dict | requests.Response | None:
            The parsed response, or the raw response if parse_request_response is False.
            None in case of an error.

    """

    retries = 0
    message = failure_message or "request"

    while True:
        try:
            response = requests.request(
                method=method,
                url=url,
                headers=headers,
                data=data,
                json=json_data,
                timeout=timeout,
            )
        except requests.exceptions.Timeout:
            if show_warning:
                self.logger.warning(
                    "%s. The request timed out.",
                    warning_message or message,
                )
            elif show_error:
                self.logger.error(
                    "Failed to %s. The request timed out.",
                    message,
                )
            else:
                self.logger.debug(
                    "Failed to %s. The request timed out.",
                    message,
                )
        except requests.exceptions.ConnectionError:
            if show_warning:
                self.logger.warning(
                    "Cannot connect to SuccessFactors while trying to %s.",
                    warning_message or message,
                )
            elif show_error:
                self.logger.error(
                    "Cannot connect to SuccessFactors while trying to %s.",
                    message,
                )
            else:
                self.logger.debug(
                    "Cannot connect to SuccessFactors while trying to %s.",
                    message,
                )
        except requests.exceptions.RequestException as exception:
            if show_error:
                self.logger.error(
                    "Failed to %s; error -> %s",
                    message,
                    exception,
                )
            elif show_warning:
                self.logger.warning(
                    "%s; warning -> %s",
                    warning_message or message,
                    exception,
                )
            else:
                self.logger.debug(
                    "Failed to %s; debug -> %s",
                    message,
                    exception,
                )
            return None
        else:
            if response.ok:
                if success_message:
                    self.logger.info(success_message)
                if parse_request_response:
                    return self.parse_request_response(response_object=response)
                return response

            if response.status_code not in retry_status_codes:
                if show_error:
                    self.logger.error(
                        "%s; status -> %s; error -> %s",
                        message,
                        response.status_code,
                        response.text,
                    )
                elif show_warning:
                    self.logger.warning(
                        "%s; status -> %s; warning -> %s",
                        warning_message or message,
                        response.status_code,
                        response.text,
                    )
                else:
                    self.logger.debug(
                        "Status -> %s; debug -> %s",
                        response.status_code,
                        response.text,
                    )
                if parse_request_response:
                    return None
                return response

            self.logger.warning(
                "Failed to %s due to transient status -> %s.",
                message,
                response.status_code,
            )

        retries += 1
        if retries <= max_retries:
            retry_interval = retries * retry_delay
            self.logger.info("Retrying in %s seconds...", str(retry_interval))
            time.sleep(retry_interval)
        else:
            break

    self.logger.error(
        "Failed to %s after %s retries.",
        message,
        str(max_retries),
    )
    return None

parse_request_response

parse_request_response(
    response_object: Response,
    additional_error_message: str = "",
    show_error: bool = True,
) -> dict | None

Convert the request response (JSON) to a Python dict in a safe way.

It also handles exceptions. It first tries to load the response.text via json.loads() that produces a dict output. Only if response.text is not set or is empty it just converts the response_object to a dict using the vars() built-in method.

Parameters:

Name Type Description Default
response_object Response

This is the response object delivered by the request call.

required
additional_error_message str

Provide a more specific error message in case of an error.

''
show_error bool

True: write an error to the log file. False: write a warning to the log file. Defaults to True.

True

Returns:

Type Description
dict | None

dict | None: The response information or None in case of an error.

Source code in packages/pyxecm/src/pyxecm_customizer/successfactors.py
def parse_request_response(
    self,
    response_object: requests.Response,
    additional_error_message: str = "",
    show_error: bool = True,
) -> dict | None:
    """Convert the request response (JSON) to a Python dict in a safe way.

    It also handles exceptions. It first tries to load the response.text
    via json.loads() that produces a dict output. Only if response.text is
    not set or is empty it just converts the response_object to a dict using
    the vars() built-in method.

    Args:
        response_object (requests.Response):
            This is the response object delivered by the request call.
        additional_error_message (str, optional):
            Provide a more specific error message in case of an error.
        show_error (bool, optional):
            True: write an error to the log file.
            False: write a warning to the log file. Defaults to True.

    Returns:
        dict | None:
            The response information or None in case of an error.

    """

    if not response_object:
        return None

    try:
        dict_object = json.loads(response_object.text) if response_object.text else vars(response_object)
    except json.JSONDecodeError as exception:
        if additional_error_message:
            message = "Cannot decode response as JSON. {}; error -> {}".format(
                additional_error_message,
                exception,
            )
        else:
            message = "Cannot decode response as JSON; error -> {}".format(
                exception,
            )
        if show_error:
            self.logger.error(message)
        else:
            self.logger.warning(message)
        return None
    else:
        return dict_object

exist_result_item

exist_result_item(
    response: dict, key: str, value: str
) -> bool

Check existence of key / value pair in the response properties of a SuccessFactors API call.

Parameters:

Name Type Description Default
response dict

REST response from a SuccessFactors API call

required
key str

The property name (key).

required
value str

The value to find in the item with the matching key.

required

Returns:

Name Type Description
bool bool

True if the value was found, False otherwise

Source code in packages/pyxecm/src/pyxecm_customizer/successfactors.py
def exist_result_item(self, response: dict, key: str, value: str) -> bool:
    """Check existence of key / value pair in the response properties of a SuccessFactors API call.

    Args:
        response (dict):
            REST response from a SuccessFactors API call
        key (str):
            The property name (key).
        value (str):
            The value to find in the item with the matching key.

    Returns:
        bool:
            True if the value was found, False otherwise

    """

    if not response:
        return False

    if "d" in response:
        data = response["d"]
        if key not in data:
            return False
        if value == data[key]:
            return True
    else:
        if key not in response:
            return False
        if value == response[key]:
            return True

    return False

get_result_value

get_result_value(
    response: dict, key: str, index: int = 0
) -> str | None

Get value of a result property with a given key of a SuccessFactors API call.

Parameters:

Name Type Description Default
response dict

REST response from a SuccessFactors REST Call

required
key str

The property name (key).

required
index int

Index to use (1st element has index 0). Defaults to 0.

0

Returns:

Type Description
str | None

str | None: The value for the key, None if not found.

Source code in packages/pyxecm/src/pyxecm_customizer/successfactors.py
def get_result_value(
    self,
    response: dict,
    key: str,
    index: int = 0,
) -> str | None:
    """Get value of a result property with a given key of a SuccessFactors API call.

    Args:
        response (dict):
            REST response from a SuccessFactors REST Call
        key (str):
            The property name (key).
        index (int, optional):
            Index to use (1st element has index 0).
            Defaults to 0.

    Returns:
        str | None:
            The value for the key, None if not found.

    """

    if not response or "d" not in response:
        return None

    data = response["d"]

    # list response types are wrapped in a "results" element
    # which is of type list
    if "results" in data:
        results = data["results"]
        if not results or not isinstance(results, list):
            return None
        try:
            value = results[index][key]
        except IndexError:
            self.logger.error(
                "Index error with index -> %s and key -> %s",
                str(index),
                key,
            )
            return None
        except KeyError:
            self.logger.error(
                "Key error with index -> %s and key -> %s",
                str(index),
                key,
            )
            return None
    else:  # simple response - try to find key in response directly:
        if key not in data:
            return None
        value = data[key]

    return value

get_saml_assertion

get_saml_assertion() -> str | None

Get SAML Assertion for SuccessFactors authentication.

Returns:

Type Description
str | None

str | None: The SAML assertion. Also stores the assertion in self._assertion. Returns None in case of an error.

Source code in packages/pyxecm/src/pyxecm_customizer/successfactors.py
def get_saml_assertion(self) -> str | None:
    """Get SAML Assertion for SuccessFactors authentication.

    Returns:
        str | None:
            The SAML assertion. Also stores the assertion in self._assertion.
            Returns None in case of an error.

    """

    request_url = self.config()["idpUrl"]

    self.logger.debug(
        "Requesting SuccessFactors SAML Assertion from -> %s",
        request_url,
    )

    idp_post_body = self.config()["idpData"]

    self._assertion = None

    response = self.do_request(
        url=request_url,
        method="POST",
        data=idp_post_body,
        timeout=REQUEST_TIMEOUT,
        failure_message="Failed to request a SuccessFactors SAML Assertion",
        show_warning=False,
        parse_request_response=False,
    )
    if not response:
        return None

    assertion = response.text
    self._assertion = assertion
    self.logger.debug("Assertion -> %s", self._assertion)
    return assertion

authenticate

authenticate(revalidate: bool = False) -> str | None

Authenticate at SuccessFactors with client ID and client secret.

Parameters:

Name Type Description Default
revalidate bool

Determine if a re-authentication is enforced (e.g. if session has timed out with 401 error).

False

Returns:

Type Description
str | None

str | None: Access token. Also stores access token in self._access_token. None in case of error

Source code in packages/pyxecm/src/pyxecm_customizer/successfactors.py
def authenticate(self, revalidate: bool = False) -> str | None:
    """Authenticate at SuccessFactors with client ID and client secret.

    Args:
        revalidate (bool, optional):
            Determine if a re-authentication is enforced
            (e.g. if session has timed out with 401 error).

    Returns:
        str | None:
            Access token. Also stores access token in self._access_token. None in case of error

    """

    if not self._assertion:
        self._assertion = self.get_saml_assertion()
        if not self._assertion:
            self.logger.error(
                "Cannot request SuccessFactors Access Token without SAML assertion.",
            )
            return None

    # Already authenticated and session still valid?
    if self._access_token and not revalidate:
        self.logger.debug(
            "Session still valid - return existing access token -> %s",
            str(self._access_token),
        )
        return self._access_token

    request_url = self.config()["authenticationUrl"]

    self.logger.debug(
        "Requesting SuccessFactors Access Token from -> %s",
        request_url,
    )

    authenticate_post_body = self.credentials()
    authenticate_post_body["assertion"] = self._assertion

    self._access_token = None

    response = self.do_request(
        url=request_url,
        method="POST",
        data=authenticate_post_body,
        timeout=REQUEST_TIMEOUT,
        failure_message="Failed to request a SuccessFactors Access Token",
        show_warning=True,
        warning_message="Unable to connect to -> {}".format(request_url),
    )
    if not response:
        return None

    authenticate_dict = response
    if not authenticate_dict or "access_token" not in authenticate_dict:
        return None
    # Store authentication access_token:
    self._access_token = authenticate_dict["access_token"]
    self.logger.debug("Access Token -> %s", self._access_token)

    return self._access_token

get_country

get_country(code: str = '') -> dict | None

Get information for a country / countries.

Parameters:

Name Type Description Default
code str

3 character code for country selection, like "USA"

''

Returns:

Type Description
dict | None

dict | None: Country details

Source code in packages/pyxecm/src/pyxecm_customizer/successfactors.py
def get_country(self, code: str = "") -> dict | None:
    """Get information for a country / countries.

    Args:
        code (str, optional):
            3 character code for country selection, like "USA"

    Returns:
        dict | None:
            Country details

    """

    if not self._access_token and not self.authenticate():
        return None

    request_url = (
        self.config()["asUrl"]
        + "Country(code='{}')".format(
            code,
        )
        if code
        else self.config()["asUrl"] + "Country"
    )

    request_header = self.request_header()

    response = self.do_request(
        url=request_url,
        method="GET",
        headers=request_header,
        timeout=REQUEST_TIMEOUT,
        failure_message="Failed to retrieve country data",
    )
    return response

get_user

get_user(
    user_id: str = "",
    field_name: str = "",
    field_value: str = "",
    field_operation: str = "eq",
    max_results: int = 1,
) -> dict | None

Get information for a SuccessFactors user account.

Inactive users are not returned by default. To query inactive users, you can explicitly include the status in a $filter or use a key predicate. If you want to query all users, use query option $filter=status in 't','f','T','F','e','d'.

Parameters:

Name Type Description Default
user_id str

The ID of the user (e.g. 106020). This is NOT the login name. If given, the user is queried by key predicate.

''
field_name str

The field name of the filter.

''
field_value str

The filter value to compare the field with.

''
field_operation str

The operation of the filter. Like "in". Defaults to "eq".

'eq'
max_results int

The maximum number of results to return. Default is 1.

1

Returns:

Type Description
dict | None

dict | None: User details

Example

Return data in the "d" dictionary:

{ '__metadata': { 'uri': "https://apisalesdemo2.successfactors.eu/odata/v2/User('106020')", 'type': 'SFOData.User' }, 'userId': '106020', 'salaryBudgetFinalSalaryPercentage': None, 'dateOfCurrentPosition': '/Date(1388534400000)/', 'matrix1Label': None, 'salary': '79860.0', 'objective': '0.0', 'ssn': None, 'state': 'New South Wales', 'issueComments': None, 'timeZone': 'Australia/Sydney', 'defaultLocale': 'en_US', 'nationality': None, 'salaryBudgetLumpsumPercentage': None, 'sysCostOfSource': None, 'ethnicity': None, 'displayName': 'Mark Burke', 'payGrade': 'GR-06', 'nickname': None, 'email': 'Mark.Burke@bestrunsap.com', 'salaryBudgetExtra2Percentage': None, 'stockBudgetOther1Amount': None, 'raiseProrating': None, 'sysStartingSalary': None, 'finalJobCode': None, 'lumpsum2Target': None, 'stockBudgetOptionAmount': None, 'country': 'Australia', 'lastModifiedDateTime': '/Date(1689005658000+0000)/', 'stockBudgetStockAmount': None, 'sciLastModified': None, 'criticalTalentComments': None, 'homePhone': None, 'veteranSeparated': False, 'stockBudgetOther2Amount': None, 'firstName': 'Mark', 'stockBudgetUnitAmount': None, 'salutation': '10808', 'impactOfLoss': None, 'benchStrength': None, 'sysSource': None, 'futureLeader': None, 'title': 'HR Business Partner', 'meritEffectiveDate': None, 'veteranProtected': False, 'lumpsumTarget': None, 'employeeClass': 'Active', 'hireDate': '/Date(1388534400000)/', 'matrix2Label': None, 'salaryLocal': None, 'citizenship': None, 'reasonForLeaving': None, 'riskOfLoss': None, 'location': 'Sydney (8510-0001)', 'reloComments': None, 'username': 'mburke', 'serviceDate': None, 'reviewFreq': None, 'salaryBudgetTotalRaisePercentage': None, ... }

Source code in packages/pyxecm/src/pyxecm_customizer/successfactors.py
def get_user(
    self,
    user_id: str = "",  # this is NOT the username but really an ID like 106020
    field_name: str = "",
    field_value: str = "",
    field_operation: str = "eq",
    max_results: int = 1,
) -> dict | None:
    """Get information for a SuccessFactors user account.

    Inactive users are not returned by default. To query inactive users,
    you can explicitly include the status in a $filter or use a key predicate.
    If you want to query all users, use query option $filter=status in 't','f','T','F','e','d'.

    Args:
        user_id (str, optional):
            The ID of the user (e.g. 106020). This is NOT the login name.
            If given, the user is queried by key predicate.
        field_name (str, optional):
            The field name of the filter.
        field_value (str, optional):
            The filter value to compare the field with.
        field_operation (str, optional):
            The operation of the filter. Like "in". Defaults to "eq".
        max_results (int, optional):
            The maximum number of results to return. Default is 1.

    Returns:
        dict | None:
            User details

    Example:
        Return data in the "d" dictionary:

        {
            '__metadata': {
                'uri': "https://apisalesdemo2.successfactors.eu/odata/v2/User('106020')",
                'type': 'SFOData.User'
            },
            'userId': '106020',
            'salaryBudgetFinalSalaryPercentage': None,
            'dateOfCurrentPosition': '/Date(1388534400000)/',
            'matrix1Label': None,
            'salary': '79860.0',
            'objective': '0.0',
            'ssn': None,
            'state': 'New South Wales',
            'issueComments': None,
            'timeZone': 'Australia/Sydney',
            'defaultLocale': 'en_US',
            'nationality': None,
            'salaryBudgetLumpsumPercentage': None,
            'sysCostOfSource': None,
            'ethnicity': None,
            'displayName': 'Mark Burke',
            'payGrade': 'GR-06',
            'nickname': None,
            'email': 'Mark.Burke@bestrunsap.com',
            'salaryBudgetExtra2Percentage': None,
            'stockBudgetOther1Amount': None,
            'raiseProrating': None,
            'sysStartingSalary': None,
            'finalJobCode': None,
            'lumpsum2Target': None,
            'stockBudgetOptionAmount': None,
            'country': 'Australia',
            'lastModifiedDateTime': '/Date(1689005658000+0000)/',
            'stockBudgetStockAmount': None,
            'sciLastModified': None,
            'criticalTalentComments': None,
            'homePhone': None,
            'veteranSeparated': False,
            'stockBudgetOther2Amount': None,
            'firstName': 'Mark',
            'stockBudgetUnitAmount': None,
            'salutation': '10808',
            'impactOfLoss': None,
            'benchStrength': None,
            'sysSource': None,
            'futureLeader': None,
            'title': 'HR Business Partner',
            'meritEffectiveDate': None,
            'veteranProtected': False,
            'lumpsumTarget': None,
            'employeeClass': 'Active',
            'hireDate': '/Date(1388534400000)/',
            'matrix2Label': None, 'salaryLocal': None,
            'citizenship': None,
            'reasonForLeaving': None,
            'riskOfLoss': None,
            'location': 'Sydney (8510-0001)',
            'reloComments': None,
            'username': 'mburke',
            'serviceDate': None,
            'reviewFreq': None,
            'salaryBudgetTotalRaisePercentage': None,
            ...
        }

    """

    if not self._access_token and not self.authenticate():
        return None

    request_url = self.config()["asUrl"] + "User"
    if user_id:
        # querying a user by key predicate:
        request_url += "('{}')".format(user_id)

    # Add query parameters (these are NOT passed via JSON body!)
    query = {}
    if field_name and field_value:
        query["$filter"] = "{} {} {}".format(
            field_name,
            field_operation,
            field_value,
        )
    if max_results > 0:
        query["$top"] = max_results
    encoded_query = urllib.parse.urlencode(query, doseq=True)
    if query:
        request_url += "?" + encoded_query

    request_header = self.request_header()

    response = self.do_request(
        url=request_url,
        method="GET",
        headers=request_header,
        timeout=REQUEST_TIMEOUT,
        failure_message="Failed to retrieve user data",
    )
    return response

get_user_account

get_user_account(username: str) -> dict | None

Get information for a SuccessFactors User Account.

Inactive users are not returned by default. To query inactive users, you can explicitly include the status in a $filter or use a key predicate. If you want to query all users, use query option $filter=status in 't','f','T','F','e','d'.

Parameters:

Name Type Description Default
username str

The login name of the user (e.g. "twalker").

required

Returns:

Type Description
dict | None

dict | None: User Account details.

Example

Return data in the "d" dictionary:

{ '__metadata': { 'uri': "https://apisalesdemo2.successfactors.eu/odata/v2/UserAccount('twalker')", 'type': 'SFOData.UserAccount' }, 'username': 'twalker', 'lastModifiedDateTime': '/Date(1692701804000+0000)/', 'accountUuid': '5c7390e0-d9d2-e348-1700-2b02b3a61aa5', 'createdDateTime': '/Date(1420745485000+0000)/', 'timeZone': 'US/Eastern', 'lastInactivationDateTime': None, 'accountIsLocked': 'FALSE', 'accountStatus': 'ACTIVE', 'defaultLocale': 'en_US', 'lastLoginFailedDateTime': None, 'accountId': '90', 'sapGlobalUserId': None, 'personIdExternal': '82094', 'userType': 'employee', 'email': 'twalker@m365x41497014.onmicrosoft.com', 'user': {'__deferred': {...}} }

Source code in packages/pyxecm/src/pyxecm_customizer/successfactors.py
def get_user_account(self, username: str) -> dict | None:
    """Get information for a SuccessFactors User Account.

    Inactive users are not returned by default. To query inactive users,
    you can explicitly include the status in a $filter or use a key predicate.
    If you want to query all users, use query option $filter=status in 't','f','T','F','e','d'.

    Args:
        username (str):
            The login name of the user (e.g. "twalker").

    Returns:
        dict | None:
            User Account details.

    Example:
        Return data in the "d" dictionary:

        {
            '__metadata': {
                'uri': "https://apisalesdemo2.successfactors.eu/odata/v2/UserAccount('twalker')",
                'type': 'SFOData.UserAccount'
            },
            'username': 'twalker',
            'lastModifiedDateTime': '/Date(1692701804000+0000)/',
            'accountUuid': '5c7390e0-d9d2-e348-1700-2b02b3a61aa5',
            'createdDateTime': '/Date(1420745485000+0000)/',
            'timeZone': 'US/Eastern',
            'lastInactivationDateTime': None,
            'accountIsLocked': 'FALSE',
            'accountStatus': 'ACTIVE',
            'defaultLocale': 'en_US',
            'lastLoginFailedDateTime': None,
            'accountId': '90',
            'sapGlobalUserId': None,
            'personIdExternal': '82094',
            'userType': 'employee',
            'email': 'twalker@m365x41497014.onmicrosoft.com',
            'user': {'__deferred': {...}}
        }

    """

    if not self._access_token and not self.authenticate():
        return None

    request_url = self.config()["asUrl"] + "UserAccount('{}')".format(username)

    request_header = self.request_header()

    response = self.do_request(
        url=request_url,
        method="GET",
        headers=request_header,
        timeout=REQUEST_TIMEOUT,
        failure_message="Failed to retrieve user data from SuccessFactors",
        show_warning=True,
        warning_message="Failed to retrieve user data from SuccessFactors",
    )
    return response

update_user

update_user(user_id: str, update_data: dict) -> dict | None

Update user data. E.g. update the user password or email.

See: https://help.sap.com/docs/SAP_SUCCESSFACTORS_PLATFORM/d599f15995d348a1b45ba5603e2aba9b/47c39724e7654b99a6be2f71fce1c50b.html?locale=en-US

Parameters:

Name Type Description Default
user_id str

The ID of the user (e.g. 106020)

required
update_data dict

The data to update the user with.

required

Returns:

Type Description
dict | None

dict | None: Request response or None if an error occurred.

Source code in packages/pyxecm/src/pyxecm_customizer/successfactors.py
def update_user(
    self,
    user_id: str,  # this is NOT the username but really an ID like 106020
    update_data: dict,
) -> dict | None:
    """Update user data. E.g. update the user password or email.

    See: https://help.sap.com/docs/SAP_SUCCESSFACTORS_PLATFORM/d599f15995d348a1b45ba5603e2aba9b/47c39724e7654b99a6be2f71fce1c50b.html?locale=en-US

    Args:
        user_id (str):
            The ID of the user (e.g. 106020)
        update_data (dict):
            The data to update the user with.

    Returns:
        dict | None:
            Request response or None if an error occurred.

    """

    if not self._access_token and not self.authenticate():
        return None

    request_url = self.config()["asUrl"] + "User('{}')".format(user_id)

    request_header = self.request_header()
    # We need to use a special MERGE header to tell
    # SuccessFactors to only change the new / provided fields:
    request_header["X-HTTP-METHOD"] = "MERGE"

    response = self.do_request(
        url=request_url,
        method="POST",
        headers=request_header,
        json_data=update_data,
        timeout=REQUEST_TIMEOUT,
        failure_message="Failed to update user with ID -> {}".format(user_id),
    )
    if response:
        self.logger.debug("User with ID -> %s updated successfully.", user_id)
        return response
    else:
        return None

get_employee

get_employee(
    entity: str = "PerPerson",
    field_name: str = "",
    field_value: str = "",
    field_operation: str = "eq",
    max_results: int = 1,
) -> dict | None

Get a list of employee(s) matching given criteria.

Parameters:

Name Type Description Default
entity str

Entity type to query. Examples are "PerPerson" (default), "PerPersonal", "PerEmail", "PersonKey", ...

'PerPerson'
field_name str

Field to search in. E.g. personIdExternal, firstName, lastName, fullName, email, dateOfBirth, gender, nationality, maritalStatus, employeeId.

''
field_value str

Value to match in the Field

''
field_operation str

The operation to apply for the filter. Default is 'eq' (equal).

'eq'
max_results int

The maximum number of results to return. Default is 1.

1

Returns:

Type Description
dict | None

dict | None: Dictionary with the SuccessFactors object data or None in case the request failed.

Examples:

Example result values for "PerPerson" inside the "d" structure: "results": [ { '__metadata': {...}, 'personIdExternal': '109031', 'lastModifiedDateTime': '/Date(1442346839000+0000)/', 'lastModifiedBy': 'admindlr', 'createdDateTime': '/Date(1442346265000+0000)/', 'dateOfBirth': '/Date(-501206400000)/', 'perPersonUuid': '0378B0E6F41444EBB90345B56D537D3D', 'createdOn': '/Date(1442353465000)/', 'lastModifiedOn': '/Date(1442354039000)/', 'countryOfBirth': 'RUS', 'createdBy': 'admindlr', 'regionOfBirth': None, 'personId': '771', 'personalInfoNav': {...}, 'emergencyContactNav': {...}, 'secondaryAssignmentsNav': {...}, 'personEmpTerminationInfoNav': {...}, 'phoneNav': {...}, 'employmentNav': {...}, ... } ]

Example result values for "PerPersonal" inside the "d" structure: "results": [ { '__metadata': { 'uri': "https://apisalesdemo2.successfactors.eu/odata/v2/PerPersonal(personIdExternal='108729',startDate=datetime'2017-03-13T00:00:00')", 'type': 'SFOData.PerPersonal' }, 'personIdExternal': '108729', 'startDate': '/Date(1489363200000)/', 'lastModifiedDateTime': '/Date(1489442337000+0000)/', 'endDate': '/Date(253402214400000)/', 'createdDateTime': '/Date(1489442337000+0000)/', 'suffix': None, 'attachmentId': None, 'preferredName': 'Hillary', 'lastNameAlt1': None, 'firstName': 'Hillary', 'nationality': 'USA', 'salutation': '30085', 'maritalStatus': '10825', 'lastName': 'Lawson', 'gender': 'F', 'firstNameAlt1': None, 'createdOn': '/Date(1489445937000)/', 'middleNameAlt1': None, 'lastModifiedBy': '82094', 'lastModifiedOn': '/Date(1489445937000)/', 'createdBy': '82094', 'middleName': None, 'nativePreferredLang': '10249', 'localNavAUS': {'__deferred': {...}}, 'localNavBGD': {'__deferred': {...}}, 'localNavHKG': {'__deferred': {...}}, 'localNavMYS': {'__deferred': {...}}, 'localNavAUT': {'__deferred': {...}}, 'localNavLKA': {'__deferred': {...}}, 'localNavPOL': {'__deferred': {...}}, 'localNavCZE': {'__deferred': {...}}, 'localNavTWN': {'__deferred': {...}}, 'localNavARE': {'__deferred': {...}}, 'localNavARG': {'__deferred': {...}}, 'localNavCAN': {'__deferred': {...}}, 'localNavNOR': {'__deferred': {...}}, 'localNavOMN': {'__deferred': {...}}, 'localNavPER': {'__deferred': {...}}, 'localNavSGP': {'__deferred': {...}}, 'localNavVEN': {'__deferred': {...}}, 'localNavZAF': {'__deferred': {...}}, 'localNavCHL': {'__deferred': {...}}, 'localNavCHE': {'__deferred': {...}}, 'localNavDNK': {'__deferred': {...}}, 'localNavGTM': {'__deferred': {...}}, 'localNavNZL': {'__deferred': {...}}, 'salutationNav': {'__deferred': {...}}, 'localNavCHN': {'__deferred': {...}}, 'localNavVNM': {'__deferred': {...}}, 'localNavIDN': {'__deferred': {...}}, 'localNavPRT': {'__deferred': {...}}, 'localNavCOL': {'__deferred': {...}}, 'localNavHUN': {'__deferred': {...}}, 'localNavSWE': {'__deferred': {...}}, 'localNavESP': {'__deferred': {...}}, 'localNavUSA': {'__deferred': {...}}, 'nativePreferredLangNav': {'__deferred': {...}}, 'maritalStatusNav': {'__deferred': {...}}, ...}

Source code in packages/pyxecm/src/pyxecm_customizer/successfactors.py
def get_employee(
    self,
    entity: str = "PerPerson",
    field_name: str = "",
    field_value: str = "",
    field_operation: str = "eq",
    max_results: int = 1,
) -> dict | None:
    """Get a list of employee(s) matching given criteria.

    Args:
        entity (str, optional):
            Entity type to query. Examples are "PerPerson" (default),
            "PerPersonal", "PerEmail", "PersonKey", ...
        field_name (str, optional):
            Field to search in. E.g. personIdExternal, firstName, lastName,
            fullName, email, dateOfBirth, gender, nationality, maritalStatus,
            employeeId.
        field_value (str, optional):
            Value to match in the Field
        field_operation (str, optional):
            The operation to apply for the filter. Default is 'eq' (equal).
        max_results (int, optional):
            The maximum number of results to return. Default is 1.

    Returns:
        dict | None:
            Dictionary with the SuccessFactors object data or None in case the request failed.

    Examples:
        Example result values for "PerPerson" inside the "d" structure:
        "results": [
            {
                '__metadata': {...},
                'personIdExternal': '109031',
                'lastModifiedDateTime': '/Date(1442346839000+0000)/',
                'lastModifiedBy': 'admindlr',
                'createdDateTime': '/Date(1442346265000+0000)/',
                'dateOfBirth': '/Date(-501206400000)/',
                'perPersonUuid': '0378B0E6F41444EBB90345B56D537D3D',
                'createdOn': '/Date(1442353465000)/',
                'lastModifiedOn': '/Date(1442354039000)/',
                'countryOfBirth': 'RUS',
                'createdBy': 'admindlr',
                'regionOfBirth': None,
                'personId': '771',
                'personalInfoNav': {...},
                'emergencyContactNav': {...},
                'secondaryAssignmentsNav': {...},
                'personEmpTerminationInfoNav': {...},
                'phoneNav': {...},
                'employmentNav': {...},
                ...
            }
        ]

        Example result values for "PerPersonal" inside the "d" structure:
        "results": [
            {
                '__metadata': {
                    'uri': "https://apisalesdemo2.successfactors.eu/odata/v2/PerPersonal(personIdExternal='108729',startDate=datetime'2017-03-13T00:00:00')",
                    'type': 'SFOData.PerPersonal'
                },
                'personIdExternal': '108729',
                'startDate': '/Date(1489363200000)/',
                'lastModifiedDateTime': '/Date(1489442337000+0000)/',
                'endDate': '/Date(253402214400000)/',
                'createdDateTime': '/Date(1489442337000+0000)/',
                'suffix': None,
                'attachmentId': None,
                'preferredName': 'Hillary',
                'lastNameAlt1': None,
                'firstName': 'Hillary',
                'nationality': 'USA',
                'salutation': '30085',
                'maritalStatus': '10825',
                'lastName': 'Lawson',
                'gender': 'F',
                'firstNameAlt1': None,
                'createdOn': '/Date(1489445937000)/',
                'middleNameAlt1': None,
                'lastModifiedBy': '82094',
                'lastModifiedOn': '/Date(1489445937000)/',
                'createdBy': '82094',
                'middleName': None,
                'nativePreferredLang': '10249',
                'localNavAUS': {'__deferred': {...}},
                'localNavBGD': {'__deferred': {...}},
                'localNavHKG': {'__deferred': {...}},
                'localNavMYS': {'__deferred': {...}},
                'localNavAUT': {'__deferred': {...}},
                'localNavLKA': {'__deferred': {...}},
                'localNavPOL': {'__deferred': {...}},
                'localNavCZE': {'__deferred': {...}},
                'localNavTWN': {'__deferred': {...}},
                'localNavARE': {'__deferred': {...}},
                'localNavARG': {'__deferred': {...}},
                'localNavCAN': {'__deferred': {...}},
                'localNavNOR': {'__deferred': {...}},
                'localNavOMN': {'__deferred': {...}},
                'localNavPER': {'__deferred': {...}},
                'localNavSGP': {'__deferred': {...}},
                'localNavVEN': {'__deferred': {...}},
                'localNavZAF': {'__deferred': {...}},
                'localNavCHL': {'__deferred': {...}},
                'localNavCHE': {'__deferred': {...}},
                'localNavDNK': {'__deferred': {...}},
                'localNavGTM': {'__deferred': {...}},
                'localNavNZL': {'__deferred': {...}},
                'salutationNav': {'__deferred': {...}},
                'localNavCHN': {'__deferred': {...}},
                'localNavVNM': {'__deferred': {...}},
                'localNavIDN': {'__deferred': {...}},
                'localNavPRT': {'__deferred': {...}},
                'localNavCOL': {'__deferred': {...}},
                'localNavHUN': {'__deferred': {...}},
                'localNavSWE': {'__deferred': {...}},
                'localNavESP': {'__deferred': {...}},
                'localNavUSA': {'__deferred': {...}},
                'nativePreferredLangNav': {'__deferred': {...}},
                'maritalStatusNav': {'__deferred': {...}}, ...}

    """

    if not self._access_token and not self.authenticate():
        return None

    # Add query parameters (these are NOT passed via JSON body!)
    query = {}
    if field_name and field_value:
        query["$filter"] = "{} {} {}".format(
            field_name,
            field_operation,
            field_value,
        )
    if max_results > 0:
        query["$top"] = max_results
    encoded_query = urllib.parse.urlencode(query, doseq=True)

    request_url = self.config()["asUrl"] + entity
    if query:
        request_url += "?" + encoded_query

    request_header = self.request_header()

    response = self.do_request(
        url=request_url,
        method="GET",
        headers=request_header,
        timeout=REQUEST_TIMEOUT,
        failure_message="Failed to retrieve employee data",
    )
    return response

get_entities_metadata

get_entities_metadata(
    entities: list | None = None,
) -> dict | None

Get the schema (metadata) for a list of entities (list can be empty to get it for all).

IMPORTANT: A metadata request using $metadata returns an XML serialization of the service, including the entity data model (EDM) and the service operation descriptions. The metadata response supports only application/xml type.

Parameters:

Name Type Description Default
entities list | None

A list of entities to deliver metadata for.

None

Returns:

Type Description
dict | None

dict | None: Dictionary with the SuccessFactors object data or None in case the request failed.

Source code in packages/pyxecm/src/pyxecm_customizer/successfactors.py
def get_entities_metadata(self, entities: list | None = None) -> dict | None:
    """Get the schema (metadata) for a list of entities (list can be empty to get it for all).

    IMPORTANT: A metadata request using $metadata returns an XML serialization of the service,
    including the entity data model (EDM) and the service operation descriptions.
    The metadata response supports only application/xml type.

    Args:
        entities (list | None, optional):
            A list of entities to deliver metadata for.

    Returns:
        dict | None:
            Dictionary with the SuccessFactors object data or None in case the request failed.

    """

    if not self._access_token and not self.authenticate():
        return None

    request_url = self.config()["asUrl"]
    if entities:
        request_url += "{}/".format(",".join(entities))
    request_url += "$metadata"

    request_header = self.request_header()
    request_header["Accept"] = "application/xml"

    response = self.do_request(
        url=request_url,
        method="GET",
        headers=request_header,
        timeout=REQUEST_TIMEOUT,
        failure_message="Failed to retrieve entity data",
        parse_request_response=False,
    )
    if response and response.status_code == 200:
        return xmltodict.parse(response.text)
    else:
        return None

get_entity_metadata

get_entity_metadata(entity: str) -> dict | None

Get the schema (metadata) for an entity.

Parameters:

Name Type Description Default
entity str

The entity to deliver metadata for.

required

Returns:

Type Description
dict | None

dict | None: Dictionary with the SuccessFactors object data or None in case the request failed.

Source code in packages/pyxecm/src/pyxecm_customizer/successfactors.py
def get_entity_metadata(self, entity: str) -> dict | None:
    """Get the schema (metadata) for an entity.

    Args:
        entity (str):
            The entity to deliver metadata for.

    Returns:
        dict | None:
            Dictionary with the SuccessFactors object data or None in case the request failed.

    """

    if not self._access_token and not self.authenticate():
        return None

    if not entity:
        return None

    request_url = self.config()["asUrl"] + "Entity('{}')?$format=JSON".format(
        entity,
    )

    request_header = self.request_header()

    response = self.do_request(
        url=request_url,
        method="GET",
        headers=request_header,
        timeout=REQUEST_TIMEOUT,
        failure_message="Failed to retrieve entity data",
    )
    return response

update_user_email

update_user_email(
    user_id: str, email_address: str, email_type: int = 8448
) -> dict | None

Update user email.

See: https://help.sap.com/docs/SAP_SUCCESSFACTORS_PLATFORM/d599f15995d348a1b45ba5603e2aba9b/7b3daeb3d77d491bb401345eede34bb5.html?locale=en-US

Parameters:

Name Type Description Default
user_id str

The ID of the user (e.g. 106020).

required
email_address str

The new email address of user.

required
email_type int

Type of the email. 8448 = Business. Defaults to 8448.

8448

Returns:

Type Description
dict | None

dict | None: Request response or None if an error occurred.

Source code in packages/pyxecm/src/pyxecm_customizer/successfactors.py
def update_user_email(
    self,
    user_id: str,  # this is NOT the username but really an ID like 106020
    email_address: str,
    email_type: int = 8448,
) -> dict | None:
    """Update user email.

    See: https://help.sap.com/docs/SAP_SUCCESSFACTORS_PLATFORM/d599f15995d348a1b45ba5603e2aba9b/7b3daeb3d77d491bb401345eede34bb5.html?locale=en-US

    Args:
        user_id (str):
            The ID of the user (e.g. 106020).
        email_address (str):
            The new email address of user.
        email_type (int, optional):
            Type of the email. 8448 = Business. Defaults to 8448.

    Returns:
        dict | None:
            Request response or None if an error occurred.

    """

    if not self._access_token and not self.authenticate():
        return None

    request_url = self.config()["asUrl"] + "upsert"

    update_data = {
        "__metadata": {
            "uri": "PerEmail(emailType='{}',personIdExternal='{}')".format(
                email_type,
                user_id,
            ),
            "type": "SFOData.PerEmail",
        },
        "emailAddress": email_address,
    }

    request_header = self.request_header()

    response = self.do_request(
        url=request_url,
        method="POST",
        headers=request_header,
        json_data=update_data,
        timeout=REQUEST_TIMEOUT,
        failure_message="Failed to set email of user with ID -> {}".format(user_id),
    )
    if response:
        self.logger.debug(
            "Email of user with ID -> %s successfully updated to -> %s.",
            user_id,
            email_address,
        )
        return response
    else:
        return None