Getting started

Quickstart

Register an app, run the authorization code flow with PKCE in Node, and read a real CV. About ten minutes.

By the end of this you will have a working "Continue with Tabbio" button and a Node server that reads the signed-in person's profile and CV. Everything below is plain Node 22, no dependencies beyond Express.

  1. Register an app

    Open the Tabbio Developer Platform at platform.tabbio.com, sign in with your Tabbio account or create a developer login, and create an app under Applications.

    • Name is what the person sees on the consent screen. Use your product name.
    • Client type: choose Confidential if you have a server, Public if this is a single page or mobile app with no backend.
    • Redirect URIs: add http://localhost:4321/callback for now. Matching is exact, so a trailing slash is a different URI.
    • Scopes: tick profile, email and cv:read.

    You get a client id straight away. A confidential app also shows a client secret once. Copy it now; it is stored hashed and cannot be shown again.

  2. Set your environment

    Terminal
    export TABBIO_CLIENT_ID="tbo_ci_9dK2mQ7xVr4tN0pLzY6b"
    export TABBIO_CLIENT_SECRET="tbo_cs_R7v2Km9Xq4Tn8Lp0Zy6Bh3Wd1Sc5Fg2Jk8Mn4Qr7"
    export TABBIO_REDIRECT_URI="http://localhost:4321/callback"
  3. Build the authorization URL

    PKCE is required for every client. Generate a random verifier, hash it, and send only the hash. The verifier stays on your server until the exchange.

    pkce.js
    import crypto from "node:crypto";
    
    const base64url = (buffer) => buffer.toString("base64url");
    
    export function createPkce() {
      const verifier = base64url(crypto.randomBytes(32));
      const challenge = base64url(crypto.createHash("sha256").update(verifier).digest());
      return { verifier, challenge };
    }
    
    export function authorizeUrl({ clientId, redirectUri, scope, state, challenge }) {
      const url = new URL("https://app.tabbio.com/oauth/authorize");
      url.searchParams.set("response_type", "code");
      url.searchParams.set("client_id", clientId);
      url.searchParams.set("redirect_uri", redirectUri);
      url.searchParams.set("scope", scope);
      url.searchParams.set("state", state);
      url.searchParams.set("code_challenge", challenge);
      url.searchParams.set("code_challenge_method", "S256");
      return url.toString();
    }

    Store verifier and state against the visitor's session, then redirect them to the URL. They land on Tabbio, sign in if they are not already, and see the consent screen.

    Where the authorize endpoint lives

    The consent screen is a page on app.tabbio.com, not on the API host. https://server.tabbio.com/oauth/authorize also works and redirects there with your query intact, so a client that reads the discovery document needs no special case.

  4. Handle the callback

    Tabbio sends the browser back to your redirect URI with code and your state. Compare the state before doing anything else, then exchange the code.

    callback.js
    export async function exchangeCode({ code, verifier, clientId, clientSecret, redirectUri }) {
      const response = await fetch("https://server.tabbio.com/oauth/token", {
        method: "POST",
        headers: { "Content-Type": "application/x-www-form-urlencoded" },
        body: new URLSearchParams({
          grant_type: "authorization_code",
          code,
          redirect_uri: redirectUri,
          code_verifier: verifier,
          client_id: clientId,
          // Public clients omit this and rely on PKCE alone.
          client_secret: clientSecret,
        }),
      });
    
      const payload = await response.json();
      if (!response.ok) {
        // The token endpoint answers with a flat OAuth error, not the envelope.
        throw new Error(`${payload.error}: ${payload.error_description ?? ""}`);
      }
      return payload;
    }

    A successful exchange gives you:

    JSON
    {
      "access_token": "tbo_at_9QpX2f",
      "token_type": "Bearer",
      "expires_in": 3600,
      "refresh_token": "tbo_rt_4Kd81a",
      "scope": "profile email cv:read"
    }

    Read the scope you were given

    The person can grant fewer scopes than you asked for. scope on this response is the set you actually hold. Check it before calling an endpoint that needs cv:read, rather than discovering the gap as a 403 later.

  5. Read the person and their CV

    Both calls are the same bearer token against server.tabbio.com.

    Terminal
    curl https://server.tabbio.com/v1/me \
      -H "Authorization: Bearer $TABBIO_ACCESS_TOKEN"

    Then the CV:

    js
    const response = await fetch("https://server.tabbio.com/v1/me/cv", {
      headers: { Authorization: `Bearer ${accessToken}` },
    });
    const { data: cv } = await response.json();
    
    console.log(cv.title);                       // "Main CV"
    console.log(cv.document.name);               // "Sara Ahmed"
    console.log(cv.document.experiences.length); // roles on the CV
    console.log(cv.pdf.url);                     // durable PDF link, no credential needed

    cv.pdf.url is a link you can put straight into an employer-facing page. It renders the current CV every time and answers 404 the moment the person disconnects you.

    An account that has never built a CV answers 404 with CV_NOT_FOUND, even with the scope granted. That is an empty state, not a failure.

The whole thing, running

Rather than assembling the pieces above, download the Express sample: one file that does all of it, including refresh and disconnect. Unzip it, set the same two environment variables, run npm start, and open http://localhost:4321.

What next