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.
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/callbackfor now. Matching is exact, so a trailing slash is a different URI. - Scopes: tick
profile,emailandcv: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.
Set your environment
Terminalexport TABBIO_CLIENT_ID="tbo_ci_9dK2mQ7xVr4tN0pLzY6b" export TABBIO_CLIENT_SECRET="tbo_cs_R7v2Km9Xq4Tn8Lp0Zy6Bh3Wd1Sc5Fg2Jk8Mn4Qr7" export TABBIO_REDIRECT_URI="http://localhost:4321/callback"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.jsimport 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
verifierandstateagainst 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/authorizealso works and redirects there with your query intact, so a client that reads the discovery document needs no special case.Handle the callback
Tabbio sends the browser back to your redirect URI with
codeand yourstate. Compare the state before doing anything else, then exchange the code.callback.jsexport 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.
scopeon this response is the set you actually hold. Check it before calling an endpoint that needscv:read, rather than discovering the gap as a 403 later.Read the person and their CV
Both calls are the same bearer token against
server.tabbio.com.Terminalcurl https://server.tabbio.com/v1/me \ -H "Authorization: Bearer $TABBIO_ACCESS_TOKEN"jsconst response = await fetch("https://server.tabbio.com/v1/me", { headers: { Authorization: `Bearer ${accessToken}` }, }); const { data: user } = await response.json(); // `email` is absent, not null, when the email scope was not granted. console.log(user.name, user.email ?? "email not shared"); console.log(user.connection.scopes); // what you actually holdPythonimport os import requests response = requests.get( "https://server.tabbio.com/v1/me", headers={"Authorization": f"Bearer {os.environ['TABBIO_ACCESS_TOKEN']}"}, ) response.raise_for_status() user = response.json()["data"] print(user["name"], user.get("email", "email not shared"))Then the CV:
jsconst 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 neededcv.pdf.urlis 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.
One file, Express and Node built-ins. Register http://localhost:4321/callback first.
The same integration in Express, Next.js, Flask, PHP and Laravel.
What next
Every parameter, every error, and what happens when a person declines.
Scopes and consentWhat the person sees, and how to handle a partial grant.
Reading the live CVThe CV document, the PDF, and polling that costs almost nothing.
TokensRefresh, rotation, revocation, and telling expired apart from revoked.