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.

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.