This guide is for developers building a third-party app that connects to TenantsDB on behalf of their users. If you are an end user who wants to connect Claude to TenantsDB, see Connect Claude instead.
TenantsDB implements standard OAuth 2.0. If you have integrated with Google, GitHub, or Stripe before, the flow will feel familiar. No proprietary SDK is needed. Any HTTP client works.
Registration is public. You do not need a TenantsDB account to register a client. Send a POST request with your app's name and one or more redirect URIs.
| Field | Type | Description | |
|---|---|---|---|
| client_name | string | required | Human-readable name for your app. Shown to users on the consent page. |
| redirect_uris | array | required | List of allowed redirect URIs. Must use https://. http://localhost is allowed for development only. |
curl -X POST https://api.tenantsdb.com/oauth/register \
-H "Content-Type: application/json" \
-d '{
"client_name": "My Awesome App",
"redirect_uris": ["https://myapp.com/oauth/callback"]
}'
{
"client_id": "tdb_oci_a9f3...",
"client_secret": "tdb_ocs_b2c1...",
"client_name": "My Awesome App",
"redirect_uris": ["https://myapp.com/oauth/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "client_secret_post"
}
client_secret is shown only in this response. Store it securely (environment variable, secrets manager). TenantsDB stores only a bcrypt hash and cannot return it again.
Build the authorization URL with your client_id, the exact redirect_uri you registered, the requested scopes, and a random state value for CSRF protection.
| Field | Type | Description | |
|---|---|---|---|
| client_id | string | required | Your registered client ID. |
| redirect_uri | string | required | Must exactly match one of the URIs you registered. |
| response_type | string | required | Must be code. |
| scope | string | required | Space-separated list. One or more of project:read, project:write, project:admin. |
| state | string | recommended | Random opaque value. TenantsDB echoes it back on redirect. Use it to detect CSRF and correlate the request. |
| code_challenge | string | recommended | PKCE challenge (RFC 7636). Base64url-encoded SHA-256 hash of a random verifier your app generates. Strongly recommended for public clients (mobile, desktop, SPA) where the client_secret cannot be kept private. |
| code_challenge_method | string | required with challenge | Must be S256. Only SHA-256 is supported. |
https://api.tenantsdb.com/oauth/authorize ?client_id=tdb_oci_a9f3... &redirect_uri=https%3A%2F%2Fmyapp.com%2Foauth%2Fcallback &response_type=code &scope=project%3Aread%20project%3Awrite &state=xyz789 &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM &code_challenge_method=S256
If the user is not logged into TenantsDB, they are redirected to the login page first. After login, they see a consent screen with your app's name, a project picker, and the scopes you requested. They click Allow or Deny.
Location: https://myapp.com/oauth/callback ?code=tdb_oac_f5e8... &state=xyz789
Location: https://myapp.com/oauth/callback ?error=access_denied &state=xyz789
state value matches what you sent before proceeding to step 3.
| Field | Type | Description | |
|---|---|---|---|
| grant_type | string | required | Must be authorization_code. |
| code | string | required | The code you received on the redirect in step 2. |
| redirect_uri | string | required | Must exactly match the redirect_uri sent in step 2. |
| client_id | string | required | Your client ID. |
| client_secret | string | required | Your client secret. |
| code_verifier | string | required if PKCE used | The original random verifier you generated before Step 2. TenantsDB hashes it with SHA-256 and compares to the challenge you sent on authorize. If the challenge was set, the verifier is required. |
curl -X POST https://api.tenantsdb.com/oauth/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "grant_type=authorization_code" \ -d "code=tdb_oac_f5e8..." \ -d "redirect_uri=https://myapp.com/oauth/callback" \ -d "client_id=tdb_oci_a9f3..." \ -d "client_secret=tdb_ocs_b2c1..." \ -d "code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
{
"access_token": "tdb_oat_3a94...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "tdb_ort_f28f...",
"scope": "project:read project:write"
}
invalid_grant. If a second exchange attempt arrives, treat it as a potential attack.
The access token works the same way as an API key. Attach it as a Bearer token in the Authorization header. Every request is scoped to the project and permissions the user approved on the consent page.
curl https://api.tenantsdb.com/workspaces \ -H "Authorization: Bearer tdb_oat_3a94..."
curl -X POST https://api.tenantsdb.com/mcp \
-H "Authorization: Bearer tdb_oat_3a94..." \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "list_workspaces",
"arguments": {}
}
}'
Refresh tokens rotate on every use. The old refresh token is revoked and a new one is returned. Always store the newest pair.
| Field | Type | Description | |
|---|---|---|---|
| grant_type | string | required | Must be refresh_token. |
| refresh_token | string | required | The refresh token from step 3 (or the most recent refresh). |
| client_id | string | required | Your client ID. |
| client_secret | string | required | Your client secret. |
curl -X POST https://api.tenantsdb.com/oauth/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "grant_type=refresh_token" \ -d "refresh_token=tdb_ort_f28f..." \ -d "client_id=tdb_oci_a9f3..." \ -d "client_secret=tdb_ocs_b2c1..."
{
"access_token": "tdb_oat_7b12...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "tdb_ort_8c44..."
}
invalid_grant. If your app accidentally saves the wrong token, the user will have to reconnect.
| Field | Type | Description | |
|---|---|---|---|
| token | string | required | The access token or refresh token to revoke. |
| client_id | string | required | Your client ID. |
| client_secret | string | required | Your client secret. |
| token_type_hint | string | optional | Either access_token or refresh_token. Accepted but not required. |
curl -X POST https://api.tenantsdb.com/oauth/revoke \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "token=tdb_oat_3a94..." \ -d "client_id=tdb_oci_a9f3..." \ -d "client_secret=tdb_ocs_b2c1..."
(empty body)
{
"issuer": "https://api.tenantsdb.com",
"authorization_endpoint": "https://api.tenantsdb.com/oauth/authorize",
"token_endpoint": "https://api.tenantsdb.com/oauth/token",
"registration_endpoint": "https://api.tenantsdb.com/oauth/register",
"revocation_endpoint": "https://api.tenantsdb.com/oauth/revoke",
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"token_endpoint_auth_methods_supported": ["client_secret_post"],
"revocation_endpoint_auth_methods_supported": ["client_secret_post"],
"scopes_supported": ["project:read", "project:write", "project:admin"],
"code_challenge_methods_supported": ["S256"]
}
{
"resource": "https://api.tenantsdb.com",
"authorization_servers": ["https://api.tenantsdb.com"],
"bearer_methods_supported": ["header"],
"scopes_supported": ["project:read", "project:write", "project:admin"],
"resource_documentation": "https://docs.tenantsdb.com"
}
| Credential | Lifetime | Rotation |
|---|---|---|
| Authorization code | 10 minutes | Single-use. Second exchange fails. |
| Access token | 1 hour | Not rotated. Refreshed via refresh_token grant. |
| Refresh token | 30 days (inactive) | Rotates on every use. Old refresh token is revoked, new one returned. |
| Client credentials | Until deleted | No rotation endpoint. Register a new client if you need fresh credentials. |
The 30-day refresh window resets every time the refresh token is used. A token used daily effectively lasts forever. A token unused for 30+ days requires the user to reconnect.
| Error | HTTP | Meaning |
|---|---|---|
| invalid_request | 400 | Missing required parameters, malformed body, or wrong Content-Type. |
| invalid_client | 401 | client_id or client_secret is wrong or the client does not exist. |
| invalid_grant | 400 | Authorization code expired, already used, or redirect_uri mismatch. Or the refresh token is expired, revoked, or from a different client. |
| unsupported_grant_type | 400 | grant_type must be authorization_code or refresh_token. |
| access_denied | 302 | User clicked Deny on the consent page. Returned as a query parameter on the redirect URL. |
| server_error | 500 | TenantsDB could not process the request. Retry with exponential backoff. |
{
"error": "invalid_grant",
"error_description": "authorization code already used"
}
Express app with two routes: /oauth/start kicks off the flow, /oauth/callback completes it and stores the tokens.
const express = require('express'); const crypto = require('crypto'); const app = express(); const CLIENT_ID = process.env.TDB_CLIENT_ID; const CLIENT_SECRET = process.env.TDB_CLIENT_SECRET; const REDIRECT_URI = 'https://myapp.com/oauth/callback'; const TDB = 'https://api.tenantsdb.com'; // PKCE helpers — generate a random verifier and its SHA-256 challenge. function base64url(buf) { return buf.toString('base64') .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); } function pkcePair() { const verifier = base64url(crypto.randomBytes(32)); const challenge = base64url(crypto.createHash('sha256').update(verifier).digest()); return { verifier, challenge }; } // Step 2: kick off the flow app.get('/oauth/start', (req, res) => { const state = crypto.randomBytes(16).toString('hex'); const { verifier, challenge } = pkcePair(); // Both must be stashed: state for CSRF check, verifier for the token call. req.session.oauthState = state; req.session.oauthVerifier = verifier; const url = `${TDB}/oauth/authorize?` + new URLSearchParams({ client_id: CLIENT_ID, redirect_uri: REDIRECT_URI, response_type: 'code', scope: 'project:read project:write', state, code_challenge: challenge, code_challenge_method: 'S256', }); res.redirect(url); }); // Step 3: exchange the code for tokens app.get('/oauth/callback', async (req, res) => { const { code, state, error } = req.query; if (error) return res.status(400).send(`OAuth error: ${error}`); if (state !== req.session.oauthState) return res.status(400).send('State mismatch'); const response = await fetch(`${TDB}/oauth/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: REDIRECT_URI, client_id: CLIENT_ID, client_secret: CLIENT_SECRET, code_verifier: req.session.oauthVerifier, }), }); const tokens = await response.json(); // Store tokens.access_token and tokens.refresh_token in your DB, keyed to this user. res.send('Connected!'); }); // Step 4: call TenantsDB on behalf of the user async function listWorkspaces(accessToken) { const r = await fetch(`${TDB}/workspaces`, { headers: { Authorization: `Bearer ${accessToken}` }, }); return r.json(); } // Step 5: refresh the token async function refresh(refreshToken) { const r = await fetch(`${TDB}/oauth/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: refreshToken, client_id: CLIENT_ID, client_secret: CLIENT_SECRET, }), }); return r.json(); } app.listen(3000);
npm install express express-session
Flask app with the same two routes. Uses requests for HTTP and secrets for the CSRF state value.
import os import base64 import hashlib import secrets import requests from flask import Flask, redirect, request, session, url_for app = Flask(__name__) app.secret_key = os.environ["FLASK_SECRET"] CLIENT_ID = os.environ["TDB_CLIENT_ID"] CLIENT_SECRET = os.environ["TDB_CLIENT_SECRET"] REDIRECT_URI = "https://myapp.com/oauth/callback" TDB = "https://api.tenantsdb.com" # PKCE helpers — generate a random verifier and its SHA-256 challenge. def pkce_pair(): verifier = secrets.token_urlsafe(32) digest = hashlib.sha256(verifier.encode("ascii")).digest() challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") return verifier, challenge # Step 2: kick off the flow @app.route("/oauth/start") def oauth_start(): state = secrets.token_urlsafe(16) verifier, challenge = pkce_pair() # Both must be stashed: state for CSRF check, verifier for the token call. session["oauth_state"] = state session["oauth_verifier"] = verifier params = { "client_id": CLIENT_ID, "redirect_uri": REDIRECT_URI, "response_type": "code", "scope": "project:read project:write", "state": state, "code_challenge": challenge, "code_challenge_method": "S256", } return redirect(f"{TDB}/oauth/authorize?{requests.compat.urlencode(params)}") # Step 3: exchange the code for tokens @app.route("/oauth/callback") def oauth_callback(): error = request.args.get("error") if error: return f"OAuth error: {error}", 400 if request.args.get("state") != session.get("oauth_state"): return "State mismatch", 400 r = requests.post(f"{TDB}/oauth/token", data={ "grant_type": "authorization_code", "code": request.args["code"], "redirect_uri": REDIRECT_URI, "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET, "code_verifier": session["oauth_verifier"], }) tokens = r.json() # Store tokens["access_token"] and tokens["refresh_token"] for this user. return "Connected!" # Step 4: call TenantsDB on behalf of the user def list_workspaces(access_token): r = requests.get( f"{TDB}/workspaces", headers={"Authorization": f"Bearer {access_token}"}, ) return r.json() # Step 5: refresh the token def refresh(refresh_token): r = requests.post(f"{TDB}/oauth/token", data={ "grant_type": "refresh_token", "refresh_token": refresh_token, "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET, }) return r.json()
pip install flask requests