API Reference

Authentication

The Tidal Control API uses Bearer tokens for authentication. Tokens are issued by your tenant's Keycloak realm using the OAuth 2.0 Device Authorization Grant (RFC 8628). This flow is designed for scripts and CLI tools that cannot open a browser window themselves, and the token acts as you — with your permissions.

If your integration runs unattended — a scheduled export into a BI tool, a server-to-server sync — use read-only machine-to-machine credentials instead. See Machine-to-machine authentication at the end of this page.

Prerequisites

You need your tenant name — the Keycloak realm identifier for your organization. It is the organization identifier in your portal URL:

https://portal.tidalcontrol.com/{your-tenant}/

For example, if your portal URL is https://portal.tidalcontrol.com/demo/, your tenant name is demo.

Throughout this guide, replace {your-tenant} with your actual tenant name.

The authentication flow

The Device Authorization Grant works in three steps:

  1. Your script requests a device code from Keycloak
  2. You (or the script user) log in via a browser URL
  3. Your script polls for the access token once login is complete

Step 1 — Request a device code

curl -X POST \
  "https://auth.tidalcontrol.com/realms/{your-tenant}/protocol/openid-connect/auth/device" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "client_id=portal&scope=openid"

Response:

{
  "device_code": "...",
  "user_code": "ABCD-1234",
  "verification_uri": "https://auth.tidalcontrol.com/realms/{your-tenant}/device",
  "verification_uri_complete": "https://auth.tidalcontrol.com/realms/{your-tenant}/device?user_code=ABCD-1234",
  "expires_in": 600,
  "interval": 5
}

Step 2 — Log in via browser

Open verification_uri_complete in a browser and log in with your Tidal Control credentials. Your script waits while you do this.

Step 3 — Poll for the token

Poll the token endpoint every interval seconds until login completes:

curl -X POST \
  "https://auth.tidalcontrol.com/realms/{your-tenant}/protocol/openid-connect/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=urn:ietf:params:oauth:grant-type:device_code&client_id=portal&device_code={device_code}"

Once the user has logged in, the response contains your access token:

{
  "access_token": "eyJhbGci...",
  "expires_in": 300,
  "refresh_token": "eyJhbGci...",
  "refresh_expires_in": 1800,
  "token_type": "Bearer"
}
Info

Access tokens expire after 5 minutes. Use the refresh_token to get a new access token without re-authenticating (see below).

Making API requests

Include the access token in every GraphQL request as a Bearer header:

curl -X POST https://portal.tidalcontrol.com/graphql \
  -H "Authorization: Bearer {access_token}" \
  -H "Content-Type: application/json" \
  -d '{"query": "{ tenant { name displayName } }"}'

Complete Python example

This script handles the full authentication flow and saves the token for reuse:

import requests
import time
import json

TENANT = "{your-tenant}"
BASE_URL = f"https://auth.tidalcontrol.com/realms/{TENANT}/protocol/openid-connect"
CLIENT_ID = "portal"
GRAPHQL_URL = "https://portal.tidalcontrol.com/graphql"


def get_token():
    # Step 1: Request device code
    r = requests.post(
        f"{BASE_URL}/auth/device",
        data={"client_id": CLIENT_ID, "scope": "openid"},
    )
    r.raise_for_status()
    device = r.json()

    print(f"\nOpen this URL in your browser to log in:")
    print(f"  {device['verification_uri_complete']}\n")

    # Step 2: Poll for token
    interval = device.get("interval", 5)
    deadline = time.time() + device["expires_in"]

    while time.time() < deadline:
        time.sleep(interval)
        r = requests.post(
            f"{BASE_URL}/token",
            data={
                "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
                "client_id": CLIENT_ID,
                "device_code": device["device_code"],
            },
        )
        if r.status_code == 200:
            token = r.json()
            print("Authentication successful.")
            return token
        body = r.json()
        if body.get("error") == "authorization_pending":
            continue  # Still waiting for user to log in
        if body.get("error") == "slow_down":
            interval += 5
            continue
        r.raise_for_status()

    raise RuntimeError("Authentication timed out.")


def refresh_token(refresh_tok):
    r = requests.post(
        f"{BASE_URL}/token",
        data={
            "grant_type": "refresh_token",
            "client_id": CLIENT_ID,
            "refresh_token": refresh_tok,
        },
    )
    r.raise_for_status()
    return r.json()


def graphql(query, variables, access_token):
    r = requests.post(
        GRAPHQL_URL,
        json={"query": query, "variables": variables},
        headers={"Authorization": f"Bearer {access_token}"},
    )
    r.raise_for_status()
    result = r.json()
    if "errors" in result:
        raise RuntimeError(result["errors"])
    return result["data"]


if __name__ == "__main__":
    token = get_token()
    data = graphql("{ tenant { name displayName } }", {}, token["access_token"])
    print(json.dumps(data, indent=2))

Refreshing an expired token

When the access token expires, use the refresh token instead of going through the full login flow again:

token = refresh_token(token["refresh_token"])
access_token = token["access_token"]
Tip

Refresh tokens also expire (typically after 30 minutes of inactivity). If a refresh fails, run the full get_token() flow again.

Machine-to-machine authentication

For unattended integrations that run without a person present — a nightly export into a BI tool, a scheduled server-to-server sync — use machine-to-machine (M2M) credentials instead of the device flow. An M2M credential is an OAuth 2.0 Client Credentials client: a client_id and client_secret pair that your script exchanges directly for an access token, with no browser step.

M2M credentials are read-only. They can run queries but cannot run mutations.

Info

M2M credentials are enabled per organization. If you don't see the API Credentials page in your settings, email support@tidalcontrol.com to have it enabled.

Creating a credential

Creating credentials requires the Super User role.

  1. Go to Settings → API Credentials.
  2. Click Create credential, give it a label, and optionally set an expiry date.
  3. Copy the client secret immediately — it is shown once and cannot be retrieved again. Store it in a secret manager or vault, never in plaintext config.

You now have a client_id and a client_secret. Together these are your credential.

Requesting a token

Exchange the credential for an access token using the client_credentials grant. There is no browser step and no scope parameter:

curl -X POST \
  "https://auth.tidalcontrol.com/realms/{your-tenant}/protocol/openid-connect/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=client_credentials" \
  -d "client_id={client_id}" \
  -d "client_secret={client_secret}"

Response:

{
  "access_token": "eyJhbGci...",
  "expires_in": 300,
  "token_type": "Bearer"
}
Info

This flow has no refresh token. When the access token expires (after about 5 minutes), request a new one with the same client_credentials call.

Include the access token in every GraphQL request as a Bearer header, exactly as in Making API requests above.

Complete shell example

Set CLIENT_SECRET to the secret you copied, then fetch a token and call the API:

export CLIENT_SECRET='<the secret you copied>'

ACCESS_TOKEN=$(curl -s -X POST \
  "https://auth.tidalcontrol.com/realms/{your-tenant}/protocol/openid-connect/token" \
  -d "grant_type=client_credentials" \
  -d "client_id={client_id}" \
  -d "client_secret=$CLIENT_SECRET" \
  | jq -r .access_token)

curl -s https://portal.tidalcontrol.com/graphql \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"query": "{ currentTenant { name } }"}'

What a credential can read

A read-only credential can read your organization's entire data set — controls, evidence, personnel, KPIs, reports, and the user directory (names and email addresses). Treat a leaked secret as full-organization data exposure and rotate it immediately.

Rotating and revoking

From Settings → API Credentials, use the row actions (kebab menu, three vertical dots) to:

  • Rotate — generate a new secret and invalidate the old one. Update your integration with the new secret.
  • Revoke — disable the credential permanently.
Tip

Access tokens stay valid until they expire (about 5 minutes), so a rotate, revoke, or expiry takes effect within one token lifetime. Allow for up to a 5-minute window before the change is fully enforced.

Previous
MCP server