Skip to content
Avanet

Authenticate the Sophos Email API and route to the correct tenant

Every Sophos Email Management API call starts with three separate steps: a service principal obtains a short-lived OAuth2 token, whoami establishes the caller’s type and ID, and only then is the target tenant paired with its regional API host. A valid token alone selects neither tenant nor data region.

The safe end state is one inseparable set: SOPHOS_ACCESS_TOKEN, SOPHOS_TENANT_ID, and SOPHOS_API_HOST. Use the global host only for identity and tenant discovery. Email operations go to ${SOPHOS_API_HOST}/email/v1 with Authorization and X-Tenant-ID.

Create a service principal for API Preview access

For a direct tenant, sign in to Sophos Fusion Admin (formerly Sophos Central Admin) as Super Admin and open Global Settings > API Credentials. In Sophos Fusion Partner, use Settings & Policies > API Credentials. While the APIs are marked Preview, API access is at SuperAdmin level. Restrict this credential to the controlled automation purpose and do not assume that a narrower selectable service-principal role is available.

  1. Create a separate credential for each application and environment.
  2. Identify owner, purpose, and target tenant in its name and description.
  3. Record Preview status and SuperAdmin-level access as a risk, and restrict use to the approved tenant.
  4. Transfer the Client ID and Client Secret immediately to an enterprise secret store.
  5. Monitor expiration, rotation, and the emergency contact outside Sophos Fusion.

Request an access token without leaving secret traces

This Bash example requires curl and jq. It reads the secret without displaying it, URL-encodes the form values, and submits them on standard input, keeping the secret out of shell history and expanded process arguments:

set -e -o pipefail
cleanup() {
  if [[ -n ${TENANTS_FILE:-} ]]; then
    rm -f "$TENANTS_FILE" "${TENANTS_FILE}.new"
  fi
  unset SOPHOS_CLIENT_SECRET TOKEN_RESPONSE PAGE_RESPONSE WHOAMI
}
trap cleanup EXIT

read -r -p "Client ID: " SOPHOS_CLIENT_ID
read -r -s -p "Client Secret: " SOPHOS_CLIENT_SECRET
printf '\n'

TOKEN_RESPONSE=$(
  printf 'grant_type=client_credentials&client_id=%s&client_secret=%s&scope=token' \
    "$(jq -rn --arg v "$SOPHOS_CLIENT_ID" '$v|@uri')" \
    "$(jq -rn --arg v "$SOPHOS_CLIENT_SECRET" '$v|@uri')" |
  curl --fail-with-body --silent --show-error \
    --request POST \
    --header 'Content-Type: application/x-www-form-urlencoded' \
    --data-binary @- \
    https://id.sophos.com/api/v2/oauth2/token
) || { cleanup; exit 1; }
unset SOPHOS_CLIENT_SECRET

SOPHOS_ACCESS_TOKEN=$(jq -er '
  select(.token_type == "bearer") |
  select((.expires_in | type) == "number" and .expires_in > 0) |
  .access_token | select(type == "string" and length > 0)
' <<<"$TOKEN_RESPONSE") || { cleanup; exit 1; }
unset TOKEN_RESPONSE

grant_type=client_credentials, scope=token, the token path, and Content-Type: application/x-www-form-urlencoded are fixed. client_id and client_secret come from your credential. A successful response includes access_token, token_type: "bearer", and expires_in. Obtain a new token through the same flow when it expires, and never log the complete token response.

Identify the caller with Who-am-I

whoami is a global discovery call. The bearer header is again passed through curl’s standard-input configuration. The token is inserted into that configuration stream, which the example does not print:

WHOAMI=$(
  printf 'header = "Authorization: Bearer %s"\n' "$SOPHOS_ACCESS_TOKEN" |
  curl --fail-with-body --silent --show-error \
    --config - https://api.central.sophos.com/whoami/v1
)

SOPHOS_ID=$(jq -er '.id | select(type == "string" and length > 0)' <<<"$WHOAMI")
SOPHOS_ID_TYPE=$(jq -er '
  .idType | select(. == "tenant" or . == "partner" or . == "organization")
' <<<"$WHOAMI")

id is the caller UUID; idType distinguishes tenant, partner, and organization; apiHosts.global is the global host; and, for a direct tenant, apiHosts.dataRegion is its regional product-API host.

For idType: "tenant", id is also the required tenant ID. Validate both routing values before accepting them:

UUID_RE='^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$'

[[ "$SOPHOS_ID_TYPE" == "tenant" ]] || {
  printf 'Direct-tenant flow requires idType tenant\n' >&2
  exit 1
}

SOPHOS_TENANT_ID=$(jq -er --arg re "$UUID_RE" '
  .id | select(type == "string" and test($re))
' <<<"$WHOAMI")
SOPHOS_API_HOST=$(jq -er '
  .apiHosts.dataRegion |
  select(type == "string" and test("^https://api-[a-z0-9-]+\\.central\\.sophos\\.com$"))
' <<<"$WHOAMI")

Missing dataRegion, an unexpected idType, or a host outside the validated format is a stop condition. Never derive a host from an example, region code, or static table.

Resolve the target tenant as a partner

For idType: "partner", the whoami id is a partner ID and must not be sent as X-Tenant-ID. Enumerate managed tenants through the global partner endpoint with X-Partner-ID. Each tenant object supplies its own id, dataRegion, and complete apiHost.

The first page requests pageTotal=true; subsequent numbered pages are read through pages.total. The local ceiling prevents an endless loop:

[[ "$SOPHOS_ID_TYPE" == "partner" ]] || {
  printf 'Partner flow requires idType partner\n' >&2
  exit 1
}

TENANTS_FILE=$(mktemp) || exit 1
trap cleanup EXIT
printf '[]\n' >"$TENANTS_FILE"

page=1
max_pages=1000
expected_pages=
while (( page <= max_pages )); do
  if (( page == 1 )); then
    PAGE_QUERY=(--data-urlencode 'page=1' --data-urlencode 'pageSize=100' --data-urlencode 'pageTotal=true')
  else
    PAGE_QUERY=(--data-urlencode "page=$page" --data-urlencode 'pageSize=100')
  fi

  PAGE_RESPONSE=$(
    printf 'header = "Authorization: Bearer %s"\n' "$SOPHOS_ACCESS_TOKEN" |
    curl --fail-with-body --silent --show-error --get --config - \
      --header "X-Partner-ID: $SOPHOS_ID" \
      "${PAGE_QUERY[@]}" \
      https://api.central.sophos.com/partner/v1/tenants
  )

  if (( page == 1 )); then
    expected_pages=$(jq -er '
      select((.items | type) == "array") |
      .pages.total | select(type == "number" and floor == . and . >= 1 and . <= 1000)
    ' <<<"$PAGE_RESPONSE")
  else
    jq -e '(.items | type) == "array"' <<<"$PAGE_RESPONSE" >/dev/null || exit 1
  fi

  jq -e --argjson page "$PAGE_RESPONSE" '. + $page.items' \
    "$TENANTS_FILE" >"${TENANTS_FILE}.new" &&
    mv "${TENANTS_FILE}.new" "$TENANTS_FILE" || exit 1

  (( page >= expected_pages )) && break
  ((page++))
done
(( page == expected_pages )) || exit 1
unset PAGE_RESPONSE PAGE_QUERY page expected_pages max_pages

Select the target by an approved UUID, never by name alone. dataRegion is a region identifier; requests use the corresponding complete apiHost:

read -r -p 'Approved target tenant UUID: ' TARGET_TENANT_ID
TARGET_TENANT=$(jq -cer --arg id "$TARGET_TENANT_ID" --arg re "$UUID_RE" '
  [.[] |
    select((.id | type) == "string" and (.id | test($re))) |
    select((.id | ascii_downcase) == ($id | ascii_downcase)) |
    select((.apiHost | type) == "string" and
           (.apiHost | test("^https://api-[a-z0-9-]+\\.central\\.sophos\\.com$")))] |
  select(length == 1) | .[0]
' "$TENANTS_FILE")

SOPHOS_TENANT_ID=$(jq -r '.id' <<<"$TARGET_TENANT")
SOPHOS_API_HOST=$(jq -r '.apiHost' <<<"$TARGET_TENANT")
rm -f "$TENANTS_FILE"
unset TARGET_TENANT TARGET_TENANT_ID TENANTS_FILE

idType: "organization" is also a separate caller context, not a tenant ID. This guide deliberately implements tenant and partner flows only; do not silently treat an organization as a partner.

Validate routing values before the first Email operation

Treat token, tenant ID, and regional host as one immutable set. This operation-neutral check validates only format and coupling; it deliberately does not call an endpoint owned by a later runbook:

[[ -n "$SOPHOS_ACCESS_TOKEN" ]] || exit 1
[[ "$SOPHOS_TENANT_ID" =~ $UUID_RE ]] || exit 1
[[ "$SOPHOS_API_HOST" =~ ^https://api-[a-z0-9-]+\.central\.sophos\.com$ ]] || exit 1
EMAIL_API_HOST="${SOPHOS_API_HOST%/}/email/v1"

Discovery passes only when whoami returns the expected caller type and ID, the tenant UUID comes from the approved mapping, and the regional host belongs to that tenant. For an executable harmless read test and its schema, continue with the mailbox automation runbook; the API overview classifies all operation families.

Every tenant-scoped Email JSON request needs the regional host from apiHosts.dataRegion or apiHost, plus /email/v1; Authorization: Bearer <access-token>; X-Tenant-ID: <tenant-uuid>; Accept: application/json; and, for JSON calls, Content-Type: application/json.

Distinguish 401, 403, 404, and 429

  • 401 Unauthorized: the credential is missing, invalid, or blocked, or the JWT expired. Check credential and secret version, then authenticate exactly once again. Do not change tenant or region to bypass 401.
  • 403 Forbidden: authentication succeeded. Because Preview access is already SuperAdmin-level, check tenant assignment, operation permission, and current API availability; changing credentials does not repair routing.
  • 404 Not Found: check the regional host, /email/v1 path, and object ID. Never probe other regions in response.
  • 429 Too Many Requests: honor available retry or rate-limit headers and retry only within a bound.

The documented general limits are: a recommended maximum of 10 calls per second, an enforced 100 per minute with bursts up to 300, a recommended 1,000 per hour, and an enforced 200,000 per day. Depending on the limit, counting applies across API credential, account, and source IP; never rotate credentials or IPs to evade it. Operation-specific limits may apply in addition.

For 429 and temporary 5xx, use exponential backoff with Full Jitter: random_between(0, min(cap, base * (2 ** attempt))). Sophos gives base = 1000 ms and cap = 30000 ms as examples. Also cap attempts and total runtime, then stop and alert. Do not automatically retry 401, 403, or 404.

Never retry write, delete, release, or clawback calls blindly: a timeout may occur after server acceptance. Check product-specific status and operation idempotency first.

Remove secrets and session data

Response bodies can contain tenant data and personal data. Logs should retain only timestamp, method, redacted path, HTTP status, and any request ID or trackingId. Never log Client Secret, access or refresh tokens, or complete response bodies. Clear temporary files and shell variables after the run:

cleanup
unset SOPHOS_ACCESS_TOKEN SOPHOS_CLIENT_ID SOPHOS_ID SOPHOS_ID_TYPE
unset SOPHOS_TENANT_ID SOPHOS_API_HOST EMAIL_API_HOST WHOAMI
trap - EXIT

For a lost or suspicious secret, create a new credential, validate routing and then run the controlled test in the applicable runbook, cut over the application, and delete the old credential. Deletion revokes future API calls; it does not undo an Email operation already performed.