Skip to content

Sign in with Rhombus

Add Sign in with Rhombus to your application using OAuth 2.0 with PKCE — register a client, request scopes, handle authorization codes, access tokens and refresh tokens, and call the API with a Bearer token.

On this page

Overview

Rhombus supports OAuth 2.0 with PKCE so you can build applications that sign users in with their existing Rhombus credentials. When a user clicks Sign in with Rhombus in your app, they're redirected to the Rhombus Console to authenticate, then bounced back to your redirect URI with a short-lived authorization code. You exchange that code for an access token and call the Rhombus API on the user's behalf.

This is the same flow used by the official Rhombus CLI — rhombus login is a working reference implementation in Go that you can read end to end.

Use this guide to build:

  • CLI tools that authenticate via browser login (like the Rhombus CLI itself)
  • Web applications that let Rhombus users sign in to your service
  • Admin dashboards and internal integrations for customers that manage many Rhombus orgs
  • Desktop applications using a local-loopback redirect

How the flow works

The Rhombus OAuth surface spans three hosts. This is a common source of confusion — your app talks to all three at different stages of the flow.

RoleUS hostEU host
Where the user signs in and approves your appconsole.rhombussystems.comconsole.eu.rhombussystems.com
Token, refresh, revocation and dynamic registration endpointsauth-web.rhombussystems.comauth-web.eu.rhombussystems.com
API calls made with the resulting access tokenapi2.rhombussystems.comapi2.eu.rhombussystems.com
Loading diagram…

Before you begin

Info

Before you start, make sure you have:

  • A Rhombus account, plus an API key (generated in the Rhombus Console under Settings → Integrations & Developer Resources → API Tokens) if you register your app with the API; dynamic client registration needs no key
  • A redirect URI your app controls — a public HTTPS URL in production, or http://localhost:<port>/callback for CLI and desktop apps
  • Basic familiarity with OAuth 2.0 Authorization Code flow and PKCE (RFC 7636)

Step 1: Register your application

Before you can start an OAuth flow, Rhombus needs to know about your application. Registration gives you a clientId and, for a confidential client, a clientSecret. A public client (a CLI or desktop app that can't keep a secret) registers with tokenEndpointAuthMethod: "none" and gets no secret.

There are two ways to get a clientId:

Register with the API

POST /api/oauth/submitApplication returns a fresh clientId and clientSecret. Store the clientSecret securely — it is not retrievable later.

import requests

headers = {
    "x-auth-scheme": "api-token",
    "x-auth-apikey": "YOUR_API_KEY",
    "Content-Type": "application/json",
}

response = requests.post(
    "https://api2.rhombussystems.com/api/oauth/submitApplication",
    headers=headers,
    json={
        "name": "Acme Console",
        "description": "Acme internal dashboard for Rhombus operators",
        "contactEmail": "engineering@acme.example",
        "redirectUri": "https://acme.example/oauth/callback",
    },
)

data = response.json()
print("clientId:    ", data["clientId"])
print("clientSecret:", data["clientSecret"])  # store securely — not retrievable later

Example response:

json
{
  "clientId": "PJjjlcKAQPCzIcaeprzEVg",
  "clientSecret": "YOUR_CLIENT_SECRET"
}
Tip

Additional endpoints are available to manage registered applications: getAllApplicationsForOrg, getApplicationByClientId, updateApplication, and deleteApplication. See the API Reference under the OAuth tag. Deleting an application revokes every grant users gave it.

Dynamic client registration

An app can also register itself with OAuth 2.0 Dynamic Client Registration (RFC 7591): post a JSON body to https://auth-web.rhombussystems.com/oauth/register, with no API key. Register a public client ("token_endpoint_auth_method": "none") when the app can't keep a secret; it then relies on PKCE alone.

cURLbash
curl -X POST "https://auth-web.rhombussystems.com/oauth/register" \
  -H "Content-Type: application/json" \
  -d '{
    "client_name": "Acme CLI",
    "redirect_uris": ["http://localhost:8765/callback"],
    "token_endpoint_auth_method": "none"
  }'

The response carries the new client_id, and a client_secret unless you registered a public client. Store the client_id and reuse it on later sign-ins instead of registering again.

Anonymous registration is limited to 10 registrations per hour from one client IP address and 100 per hour in total. Over either limit, the endpoint returns 429 Too Many Requests with a Retry-After header and an OAuth error body whose error is temporarily_unavailable; wait the number of seconds in Retry-After before trying again.

Step 2: Build the authorization URL

Rhombus uses PKCE (Proof Key for Code Exchange) to protect against authorization code interception. For every login attempt, generate:

  • A code verifier — a 43–128 character URL-safe random string
  • A code challenge — the SHA-256 hash of the verifier, base64url-encoded (without padding)
  • A state parameter — an unguessable random value used to prevent CSRF

Persist the codeVerifier and state alongside the user's session (or, for CLI tools, in process memory) until the callback lands. You'll need both.

Name the access your app needs in scope, space-delimited (see Scopes). The examples request all three scopes because this guide later mints an API key; an app that only reads requests scope=read.

import base64
import hashlib
import secrets
from urllib.parse import urlencode

def new_pkce_pair():
    verifier = secrets.token_urlsafe(64)  # 86 chars after base64
    digest = hashlib.sha256(verifier.encode("ascii")).digest()
    challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
    return verifier, challenge

client_id    = "PJjjlcKAQPCzIcaeprzEVg"
redirect_uri = "https://acme.example/oauth/callback"

code_verifier, code_challenge = new_pkce_pair()
state = secrets.token_urlsafe(32)

# Save code_verifier + state in the user's session before redirecting.

params = {
    "client_id":             client_id,
    "redirect_uri":          redirect_uri,
    "response_type":         "code",
    "scope":                 "read write physical",
    "state":                 state,
    "code_challenge":        code_challenge,
    "code_challenge_method": "S256",
}
authorize_url = f"https://console.rhombussystems.com/oauth/authorize?{urlencode(params)}"
print(authorize_url)
Warning

The authorization endpoint uses standard OAuth 2.0 parameter names — client_id, redirect_uri, response_type=code, scope, state, code_challenge, and code_challenge_method=S256. Only S256 is supported for the challenge method. (For backward compatibility the console also accepts the short forms redirect and challenge, but prefer the standard names shown above.)

Redirect the user's browser to the URL you built. They'll sign in to Rhombus and approve your application.

Step 3: Handle the redirect callback

After the user authenticates, Rhombus redirects to your redirectUri with query parameters:

On success, the callback carries:

ParameterDescription
codeAuthorization code to exchange for an access token. Short-lived.
stateThe state value you sent — you must verify it matches.
iss(when configured) The issuer identifier (RFC 9207). If present, validate it matches the expected authorization server.

On failure, the callback carries error (e.g., access_denied) and error_description (human-readable detail) instead of code.

Warning

Always verify the state parameter matches what you sent. A mismatch indicates a possible CSRF attack — abort the flow.

python
from flask import Flask, request, session, abort

app = Flask(__name__)

@app.route("/oauth/callback")
def callback():
    if request.args.get("error"):
        return f"Login failed: {request.args['error_description']}", 400

    code  = request.args.get("code")
    state = request.args.get("state")

    if state != session.pop("oauth_state", None):
        abort(400, "state mismatch")

    code_verifier = session.pop("oauth_verifier", None)
    # Proceed to Step 4 — exchange `code` for an access token.
    ...

Step 4: Exchange the code for an access token

Call POST https://auth-web.rhombussystems.com/oauth/token with the authorization code and your PKCE verifier. This is a standard OAuth 2.0 token request: send the parameters form-encoded (application/x-www-form-urlencoded), not as JSON, and authenticate your client with its clientId/clientSecret — either via HTTP Basic auth (client_secret_basic) or in the request body (client_secret_post, shown below). A public client (registered with token_endpoint_auth_method none) has no secret and sends only client_id. This is a different host from the main API — the /oauth/token endpoint lives on auth-web.rhombussystems.com.

import requests

token_response = requests.post(
    "https://auth-web.rhombussystems.com/oauth/token",
    headers={"Content-Type": "application/x-www-form-urlencoded"},
    data={
        "grant_type":    "authorization_code",
        "code":          code,
        "redirect_uri":  redirect_uri,
        "code_verifier": code_verifier,
        "client_id":     client_id,
        "client_secret": client_secret,
    },
)
token_response.raise_for_status()
tokens = token_response.json()

access_token  = tokens["access_token"]
refresh_token = tokens["refresh_token"]
expires_in_s  = tokens["expires_in"]
granted_scope = tokens["scope"]

Example response:

json
{
  "access_token": "AAAAAAAAAAAAAAAAAAAAAA",
  "refresh_token": "BBBBBBBBBBBBBBBBBBBBBB",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "read write physical"
}

scope lists the scopes the token carries, space-delimited.

Step 5: Call the Rhombus API

Send the access token in the standard header on every Rhombus API call:

  • Authorization: Bearer <accessToken>

A Bearer token with no x-auth-scheme header is read as an OAuth access token, and Authorization: Bearer carries nothing else. The same header works on the REST API (api2), the media hosts and the event WebSocket handshake. The equivalent header pair x-auth-scheme: api-oauth-token and x-auth-access-token: <accessToken> is also accepted.

import requests

headers = {
    "Authorization": f"Bearer {access_token}",
    "Content-Type": "application/json",
}

response = requests.post(
    "https://api2.rhombussystems.com/api/user/getUsersInOrg",
    headers=headers,
    json={},
)

users = response.json().get("users", [])
for user in users:
    print(user.get("email"), "—", user.get("name"))
Success

If this call returns a list of users, your OAuth flow is working end to end. The user has authenticated, you have an access token, and you're calling the API on their behalf.

Scopes

A scope limits what an access token can do. Request scopes in the scope parameter of the authorization URL, space-delimited (for example scope=read%20write). Values Rhombus doesn't know are ignored, and a request with no scope gets all three. The consent screen lists the scopes your app asks for:

ScopeConsent screenCovers
readRead: view cameras, footage and settingsOperations whose names start with get, find, search, list, export, count, query, lookup, fetch, download, view, describe or preview; media and live video; the event WebSocket
writeWrite: change settings and dataEvery other operation
physicalPhysical: unlock doors and elevators, lockdown, alarms, speakers and robotsDoor and elevator unlocks (integration unlocks included), lockdown, state overrides, relays, first-in, arm and disarm, escalate to alarm, speaker playback, the robot live socket, and sending talk audio

Each operation's page in the API reference shows the scope it needs.

  • Scopes never add access. A token can do only what both the user's role and its scopes allow (see OAuth scopes).
  • The event WebSocket needs read. A token without it is refused at the handshake.
  • Talk audio is checked per message. Sending audio without physical closes the socket with code 1008.
  • Creating, rotating and renewing API keys needs all three scopes (see Mint a long-lived API key).
  • Token responses include scope, the scopes the token carries. The authorization server's discovery document (https://auth-web.rhombussystems.com/.well-known/oauth-authorization-server) and the API's protected-resource document (https://api2.rhombussystems.com/.well-known/oauth-protected-resource) list the supported scopes in scopes_supported.

A call that needs a scope the token doesn't carry returns 403 with errorCode PERMISSION_REQUIRED and a header naming the scope it needs:

http
HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope", scope="physical"

To get the missing scope, send the user through the authorization flow again with it added to scope.

Access token lifetime

The expires_in field on the token response tells you how long (in seconds) the access token is valid (typically one hour). When it expires, the Rhombus API returns 401 with errorCode CREDENTIAL_EXPIRED; refresh it.

A client that stores the refresh token keeps access as long as it keeps refreshing; the Rhombus CLI works this way. A background service, daemon or scheduled job that shouldn't depend on a user's sign-in can instead mint an API key with the access token (see Mint a long-lived API key).

Refresh tokens

Exchange the refresh token for a new access token at the same /oauth/token endpoint, form-encoded:

cURLbash
curl -X POST "https://auth-web.rhombussystems.com/oauth/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d grant_type=refresh_token \
  -d refresh_token="$REFRESH_TOKEN" \
  -d client_id="$CLIENT_ID" \
  -d client_secret="$CLIENT_SECRET"   # confidential clients only
  • Public clients (registered with token_endpoint_auth_method none, protected by PKCE alone) send only client_id. Confidential clients also send client_secret, in the body or with HTTP Basic auth.
  • Every refresh rotates the refresh token. The response carries a new refresh_token; store it, because the one you sent stops working.
  • Never reuse a replaced refresh token. Within 30 seconds of being replaced it's refused. After 30 seconds, presenting it revokes the whole grant, including the API keys minted with it, and the user has to sign in again. If several processes share one session, let only one of them refresh.
  • A refresh token unused for 90 days expires.
  • A failed refresh returns 400 with the OAuth error invalid_grant: the refresh token was replaced, expired or revoked. Send the user through the sign-in flow again.

Access and refresh tokens are opaque strings, not JWTs.

Revoke a grant

To end a grant, post the refresh token to https://auth-web.rhombussystems.com/oauth/token/revoke, form-encoded, with token, client_id and, for a confidential client, client_secret. Revoking a grant:

  • Stops its tokens within 60 seconds on every host. Open event WebSocket connections receive a STOMP ERROR frame and close with code 1008; open media sockets (live video, talk, robot, PTZ) close with code 1008.
  • Deletes the API keys minted with the grant.
  • Ends the federated tokens minted with the grant.

Deleting the application revokes all of its grants. See also Permissions and key rotation.

Mint a long-lived API key

Once a user has signed in with OAuth, you can trade the short-lived access token for a long-lived API key, for a service that keeps calling the API without the user's session.

Call POST /api/integrations/org/submitApiTokenApplication with Authorization: Bearer <accessToken>. The token needs all three scopes (read, write and physical). The body names the new key's role in permissionGroupUuid: the user's own role, or one they're allowed to assign. List roles with getPermissionGroups and the user's assignable roles with getPermissionsForCurrentUser. To give the key an expiry, add expiresAtMs (epoch milliseconds). Some refusals, such as a role the user can't assign, return HTTP 200 with "error": true and an errorMsg, so check the body as well as the status.

import requests

response = requests.post(
    "https://api2.rhombussystems.com/api/integrations/org/submitApiTokenApplication",
    headers={
        "Authorization": f"Bearer {access_token}",
        "Content-Type": "application/json",
    },
    json={
        "displayName": "Acme Console — background worker",
        "permissionGroupUuid": role_uuid,
        "authType": "API_TOKEN",
    },
)

data = response.json()
if data.get("error"):
    raise RuntimeError(data.get("errorMsg"))
api_key = data["apiKey"]  # store this securely — treat it like a password

From that point on, use the API key with the standard x-auth-scheme: api-token + x-auth-apikey headers — no further OAuth calls required. For a certificate (mTLS) key, send "authType": "CERT" with a csr whose signature verifies; see API keys and certificates. The same access token can rotate a key (POST /api/integrations/org/rotateApiToken) or renew a certificate key's certificate (POST /api/integrations/org/renewApiTokenCert), with the same three scopes; see Manage keys with the API.

Warning

Treat minted API keys like passwords. They act with the role you passed in permissionGroupUuid and work until you delete them or their expiresAtMs passes. Revoking the OAuth grant they were minted with deletes them. Store them encrypted at rest, never in source control, and rotate or delete unused keys.

Working with partner accounts

Users in a partner (MSP or reseller) organization can complete the same Sign in with Rhombus flow, and their access token goes in the same Authorization: Bearer <accessToken> header. (x-auth-scheme: partner-api-oauth-token with x-auth-access-token is accepted as an alias.)

To act inside a managed client organization, add x-auth-org: <clientOrgUuid>, exactly as with a partner API key. It's a header only, and a blank value counts as absent. A partner token that calls a client-level endpoint without x-auth-org acts in the partner organization, and the response carries Deprecation and Sunset headers: see Partner API Calls.

The callback doesn't tell you whether the user is a partner. If you call POST /api/integrations/org/submitApiTokenApplication with a partner user's token, it returns 403 Forbidden. Retry with the partner endpoint below. The Rhombus CLI detects partner accounts the same way at sign-in, and rhombus login --partner skips the check.

Mint a partner API key

Call POST /api/partner/submitApiTokenApplication with the access token, without x-auth-org. The body is the same as the organization endpoint. The token needs all three scopes, and the user's partner role must include API administration.

cURLbash
curl -X POST "https://api2.rhombussystems.com/api/partner/submitApiTokenApplication" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"displayName": "Acme MSP integration", "permissionGroupUuid": "PARTNER_ROLE_UUID", "authType": "API_TOKEN"}'

The response contains apiKey. Use it with x-auth-scheme: api-token and x-auth-apikey. To act inside a managed client organization, add x-auth-org: <clientOrgUuid>. See Partner API Calls, which also covers rotating and renewing partner keys.

Reference implementation

The Rhombus CLI is a production reference for everything in this guide. rhombus login signs in with OAuth and keeps the profile's tokens fresh instead of creating an API key. cmd/login.go walks the full flow end to end: dynamic registration as a public client, PKCE generation, local callback server, authorization URL construction, token exchange, and storing the tokens; the CLI refreshes them before they expire.

If something in your implementation isn't working, diff your behavior against the CLI — it's the canonical example.

Troubleshooting

state mismatch on callback

Your callback received a state value different from what you sent. Verify you're persisting the state you generated in Step 2 alongside the user's session (or in memory for CLI tools) and comparing it on the callback. A persistent mismatch can indicate a CSRF attempt — abort the flow rather than retrying silently.

Token exchange returns HTTP 400 or an `error` response

Common causes:

  • redirect_uri mismatch — the redirect_uri in the token exchange body must match exactly (including scheme, host, port, and path) the redirect_uri you sent in Step 2 and the URI registered with your OAuth application.
  • Expired or reused code — authorization codes expire after 5 minutes and work once. Exchange them on callback.
  • code_verifier doesn't hash to code_challenge — verify you're using SHA-256 and base64url encoding without = padding on both the challenge generation and the verifier transmission.
  • Wrong content type or client auth — the token endpoint expects application/x-www-form-urlencoded (not JSON). A confidential client sends its clientId/clientSecret via HTTP Basic auth or in the form body (client_secret_post); a public client sends only client_id. The token request does not use an x-auth-scheme header.
Refresh returns 400 invalid_grant

The refresh token was replaced by an earlier refresh, went unused for 90 days, or its grant was revoked: through the revoke endpoint, by deleting the app, or because a replaced refresh token was presented more than 30 seconds after it was replaced. Always store the newest refresh_token from each response. Once a refresh fails, send the user through the sign-in flow again.

API calls fail with 401 or 403 and an access token

Send the token as Authorization: Bearer <token> (or with x-auth-scheme: api-oauth-token and x-auth-access-token), never in x-auth-apikey. A 401 means the token is missing, malformed, unknown, expired or revoked; errorCode says which (UNAUTHENTICATED, INVALID_CREDENTIAL, CREDENTIAL_EXPIRED, CREDENTIAL_REVOKED). Refresh an expired token. A 403 with PERMISSION_REQUIRED means the token is valid but the user's role or the token's scopes don't allow the call; when a scope is missing, the WWW-Authenticate header names it (see Scopes). See Reading authentication errors.

Redirect lands on the console login page again with no code

The client_id you're sending may be unrecognized. Double-check you're using the clientId returned from submitApplication, not the application UUID from a different response. If you rotated applications, the old clientId is no longer valid.

Next steps