OAuth

Integrate your app

Step-by-step: add Sign in with Teamcast to your application in under an hour.

This guide walks you through adding Teamcast OAuth to an external application — a recruiter portal, an ATS, a custom internal tool. When you're done, your users will click “Connect with Teamcast”, sign in with their Teamcast credentials, approve the permissions you've requested, and your backend can drive Teamcast APIs on their behalf.

Teamcast implements OAuth 2.1 (authorization code + PKCE + rotating refresh tokens). Any standards-compliant OAuth client works — passport-oauth2, openid-client, Auth.js, Spring Security, authlib, etc. — configuration is just URLs and scopes.

Step 1 — Register a client

Ask your Teamcast SuperAdmin (or do it yourself if you are one) to register your app at /super-admin/oauth-clients. Provide:

FieldExample
name"Acme Recruiter"
clientTypeCONFIDENTIAL (server-side) or PUBLIC (SPA/mobile)
redirectUris["https://app.acme.com/oauth/callback"]
scopes["interview:read","interview:create","interview:update","interview:approve","candidate:read"]
allowedGrantTypes["authorization_code","refresh_token"]

You'll get a client_id, and for confidential clients a client_secret shown once. Store both as secrets. See Register a client for every field and the PATCH/DELETE endpoints.

Step 2 — Generate state + PKCE per login

ts
import crypto from 'crypto';

const base64url = (buf: Buffer) =>
  buf.toString('base64').replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_');

const state = base64url(crypto.randomBytes(16));
const codeVerifier = base64url(crypto.randomBytes(48));
const codeChallenge = base64url(
  crypto.createHash('sha256').update(codeVerifier).digest()
);

// Persist { state, codeVerifier } keyed by the user's server-side session.
// The state is the bridge back to the codeVerifier on /callback.

Step 3 — Redirect to /oauth/authorize

ts
const url = new URL('https://mayaapi.teamcast.ai/oauth/authorize');
url.searchParams.set('response_type',         'code');
url.searchParams.set('client_id',             CLIENT_ID);
url.searchParams.set('redirect_uri',          'https://app.acme.com/oauth/callback');
url.searchParams.set('scope',                 'interview:read interview:create');
url.searchParams.set('state',                 state);
url.searchParams.set('code_challenge',        codeChallenge);
url.searchParams.set('code_challenge_method', 'S256');
url.searchParams.set('prompt',                'select_account');  // recommended

res.redirect(url.toString());
Sending prompt=select_account forces Teamcast to display the “Continue as X / Use a different account” confirmation on every connect. Prevents a shared-browser user from silently inheriting a previous session — critical UX for multi-user recruiter workstations.

Step 4 — Exchange code for tokens

ts
app.get('/oauth/callback', async (req, res) => {
  const { code, state, error } = req.query;
  if (error) return res.status(400).send(String(error));

  const pending = await lookupPending(String(state));  // your session store
  if (!pending) return res.status(400).send('unknown_state');

  const basic = Buffer.from(
    encodeURIComponent(CLIENT_ID) + ':' + encodeURIComponent(CLIENT_SECRET)
  ).toString('base64');

  const r = await fetch('https://mayaapi.teamcast.ai/oauth/token', {
    method: 'POST',
    headers: {
      'Content-Type':  'application/x-www-form-urlencoded',
      Authorization:   'Basic ' + basic,
    },
    body: new URLSearchParams({
      grant_type:    'authorization_code',
      code:          String(code),
      redirect_uri:  'https://app.acme.com/oauth/callback',
      code_verifier: pending.codeVerifier,
    }),
  });
  const tokens = await r.json();
  // { access_token, refresh_token, expires_in, scope, token_type }

  await saveTokensForUser(req.session.userId, tokens);
  res.redirect('/my-app/connected');
});

Step 5 — Call Teamcast APIs

Attach the access token to every request. The API enforces both permission and scope on protected endpoints.

bash
curl https://mayaapi.teamcast.ai/api/v1/interviews \
  -H "Authorization: Bearer <access_token>"
See the Interviews, Workflow, and Candidate Access references for the endpoints OAuth tokens can reach.

Step 6 — Keep the access token fresh

Access tokens are short-lived (default 15 min). Refresh tokens rotate on every use. Wrap every API call with a “fresh token” helper:

ts
async function getFreshAccessToken(userId: string) {
  const stored = await load(userId);
  if (stored.expiresAt - Date.now() > 60_000) return stored.accessToken;

  const r = await fetch('https://mayaapi.teamcast.ai/oauth/token', {
    method: 'POST',
    headers: {
      'Content-Type':  'application/x-www-form-urlencoded',
      Authorization:   'Basic ' + basic,
    },
    body: new URLSearchParams({
      grant_type:    'refresh_token',
      refresh_token: stored.refreshToken,
    }),
  });
  if (!r.ok) throw new Error('reconnect_required');
  const tokens = await r.json();

  // CRITICAL: always persist the new refresh_token. Presenting an old
  // (already-rotated) refresh triggers family-wide revocation.
  await save(userId, tokens);
  return tokens.access_token;
}
Refresh tokens are single-use. Teamcast issues a new refresh on every /oauth/token call — you must save it and use it on the next refresh. If you present a refresh token that was already rotated out, Teamcast revokes the entire token family and the user must reconnect.

Step 7 — Disconnect

When the user disconnects your integration, revoke the tokens cleanly:

bash
curl -X POST https://mayaapi.teamcast.ai/oauth/revoke \
  -u "<CLIENT_ID>:<CLIENT_SECRET>" \
  -d "token=<refresh_token>" \
  -d "token_type_hint=refresh_token"

Revoking the refresh token invalidates the entire family. Delete your local copies after a successful revoke.

Common pitfalls

SymptomFix
redirect_uri not registeredRegistered URI must match exactly. No trailing slash diff, no port diff, no http/https swap.
invalid_grant: pkce_failedVerifier didn't match the challenge. Store and retrieve the same verifier for the matching state.
invalid_grant: reuse_detectedYou reused a refresh token that was already rotated out. Always persist the new refresh on each /token call.
User tenant does not match client tenantClient audience is TENANT. Use a PLATFORM client, or have users from the bound tenant sign in.
Missing required scope(s): ...Endpoint requires a scope you didn't request. Add it to the scope param (and to the client record if missing there too).
Invalid OAuth token issuerYour OAUTH_ISSUER in env doesn't match the iss claim Teamcast signs. Set them equal.
Was this page helpful?