Build an Integration
Developer guide for integrating TenantsDB into your own app using OAuth 2.0.

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.

TenantsDB implements RFC 6749 (OAuth 2.0), RFC 7591 (Dynamic Client Registration), RFC 7636 (PKCE), RFC 7009 (Token Revocation), RFC 8414 (Server Metadata), and RFC 9728 (Protected Resource Metadata).

Integration Flow
5 steps from zero to a working integration.
Build flow
Step 1
Register your app and get client credentials
Step 2
Send your user to TenantsDB to approve access
Step 3
Exchange the returned authorization code for access and refresh tokens
Step 4
Call the TenantsDB REST API or MCP server with the access token
Step 5
Refresh the access token before it expires

Step 1: Register Your App
Get a client_id and client_secret by registering your app via Dynamic Client Registration (RFC 7591).

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.

POST /oauth/register
Request Body
FieldTypeDescription
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.
Example Request
curl
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"]
  }'
Response
HTTP 201
{
  "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"
}
The 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.

Step 2: Authorize
Redirect the user to TenantsDB's consent page. They log in, pick a project, and approve the requested scopes.

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.

GET /oauth/authorize
Query Parameters
FieldTypeDescription
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.
Example Redirect URL
URL
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
What the User Sees

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.

Redirect Response (Allow)
HTTP 302
Location: https://myapp.com/oauth/callback
  ?code=tdb_oac_f5e8...
  &state=xyz789
Redirect Response (Deny)
HTTP 302
Location: https://myapp.com/oauth/callback
  ?error=access_denied
  &state=xyz789
The authorization code expires after 10 minutes and can be used only once. Always verify the state value matches what you sent before proceeding to step 3.

Step 3: Exchange Code for Tokens
Your server exchanges the authorization code for an access token and refresh token.
POST /oauth/token
Request Body (form-encoded)
FieldTypeDescription
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.
Example Request
curl
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"
Response
HTTP 200
{
  "access_token": "tdb_oat_3a94...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "tdb_ort_f28f...",
  "scope": "project:read project:write"
}
Authorization codes are single-use. Attempting to exchange the same code twice returns invalid_grant. If a second exchange attempt arrives, treat it as a potential attack.

Step 4: Call TenantsDB APIs
Send the access token as a Bearer token on every API or MCP call.

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.

REST API Example
curl
curl https://api.tenantsdb.com/workspaces \
  -H "Authorization: Bearer tdb_oat_3a94..."
MCP Example
curl
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": {}
    }
  }'
See the API Reference for all REST endpoints and the MCP Server Reference for all 26 MCP tools.

Step 5: Refresh the Access Token
Access tokens expire after 1 hour. Use the refresh token to get a new one without user involvement.

Refresh tokens rotate on every use. The old refresh token is revoked and a new one is returned. Always store the newest pair.

POST /oauth/token
Request Body (form-encoded)
FieldTypeDescription
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.
Example Request
curl
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..."
Response
HTTP 200
{
  "access_token": "tdb_oat_7b12...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "tdb_ort_8c44..."
}
After a successful refresh, discard the old refresh_token. Trying to use it again returns invalid_grant. If your app accidentally saves the wrong token, the user will have to reconnect.

Revoking Tokens
RFC 7009 token revocation. Revoke a specific token when the user disconnects your app or deletes their account.
POST /oauth/revoke
Request Body (form-encoded)
FieldTypeDescription
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.
Example Request
curl
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..."
Response
HTTP 200
(empty body)
Per RFC 7009, unknown tokens also return 200 to avoid leaking which tokens are valid. Revoking a refresh token also invalidates its paired access token. Users can also revoke your app at any time from their TenantsDB dashboard Connected Apps page.

Discovery Metadata
Two well-known endpoints let clients auto-discover TenantsDB's OAuth configuration.
GET /.well-known/oauth-authorization-server
RFC 8414 Authorization Server Metadata. Lists all OAuth endpoints, supported grant types, scopes, and auth methods.
Response
HTTP 200
{
  "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"]
}
GET /.well-known/oauth-protected-resource
RFC 9728 Protected Resource Metadata. Used by MCP clients to discover the authorization server after receiving a 401 with WWW-Authenticate.
Response
HTTP 200
{
  "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"
}

Token Lifetimes & Rotation
How long each credential lives.
CredentialLifetimeRotation
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 Codes
Standard OAuth 2.0 error responses (RFC 6749 §5.2).
ErrorHTTPMeaning
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.
Example Error Response
HTTP 400
{
  "error": "invalid_grant",
  "error_description": "authorization code already used"
}

Code Examples
End-to-end implementations in Node.js and Python. Register once, then run the full flow.
Node.js (Express)

Express app with two routes: /oauth/start kicks off the flow, /oauth/callback completes it and stores the tokens.

server.js
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);
Install
npm install express express-session
Python (Flask)

Flask app with the same two routes. Uses requests for HTTP and secrets for the CSRF state value.

app.py
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()
Install
pip install flask requests