1. Core Concepts
  2. SSO (Single Sign On)

SSO (Single Sign On) lets users log in to your app using an account they already have, so they do not need to create a new username and password for every service.

Backboard SSO lets your users log in or sign up using a Backboard account. Backboard SSO uses the OAuth 2.0 protocol for authentication and authorization. You redirect users to Backboard, they log in or create a new account, approve access, and your app gets back a verified identity with an access token.

For endpoint-level reference, see OAuth API Reference.

Once signed up, your users can also log in to the Backboard Dashboard to view their usage, manage their memories, and buy credits.

Think of it like “Sign in with Google” but for Backboard. Your users get a familiar login experience, and you get verified user info without handling passwords.

Use the Backboard mark on Log in with Backboard buttons, settings screens, or anywhere you want users to recognize the integration.

Backboard mark — blue vertical bars on a black field

​
How It Works

Here is what happens when a user clicks “Log in with Backboard” in your app:

1

Your app redirects the user to Backboard

You send the user to https://app.backboard.io/api/oauth/authorize with your app’s client_id and a redirect_uri.

2

The user logs in (or signs up) and approves your app

Backboard shows a consent screen. The user logs in with an existing account or creates a new one, and clicks “Allow”.

3

Backboard redirects back to your app with a code

The user is sent back to your redirect_uri with a short lived authorization code in the URL.

4

Your server exchanges the code for tokens

Your backend sends the code (along with your OAuth App Secret) to Backboard’s token endpoint and gets back an access token, refresh token, and user info.

​
Step by Step Setup

​
1. Register Your OAuth App

Go to the Backboard Dashboard and open OAuth Apps from the sidebar. Click Create OAuth App.

OAuth Apps page in the dashboard — list of apps with OAuth App Key, mode, and Create OAuth App

​
2. Save Your Credentials

After creating the app, you will see two values:

  • OAuth App Key: your OAuth 2.0 client_id. Safe to embed in frontend code if needed.
  • OAuth App Secret: your client_secret. Private; use only on your server or in a secret manager.

The OAuth App Secret is only shown once. Copy it immediately and store it in a secure place like an environment variable. If you lose it, you will need to rotate it from the dashboard (which invalidates the old one).

​
3. Create API_Key

If you choose individual mode for your app, you don’t need to do this.

If you choose organization mode, please follow the instruction below:

Go to the Backboard Dashboard and click the organization button (red box in the picture below) from the sidebar. Choose the organization with same name as your OAuth app, then create your api_key.

OAuth Apps page in the dashboard — Switch organization

OAuth Apps page in the dashboard — Create api_key

​
Building the Login Flow

​
Step 1: Redirect the User to Backboard

When the user clicks your “Log in with Backboard” button, you call the authorization endpoint GET /api/oauth/authorize with query parameters as in this example:

from urllib.parse import urlencode

params = {
    "client_id": "YOUR_OAuth_App_Key",
    "redirect_uri": "http://localhost:3000/auth/callback",
    "response_type": "code",
    "scope": "openid email profile",
    "state": "RANDOM_STRING",
    "code_challenge": "GENERATED_CHALLENGE",
    "code_challenge_method": "S256",
    "nonce": "OPTIONAL_RANDOM_STRING",
}
authorize_url = f"https://app.backboard.io/api/oauth/authorize?{urlencode(params)}"
# Redirect the user's browser to authorize_url (302 from your server or window.location)

It is a browser entry point. This means you are not posting JSON to a “REST” endpoint. You send the user’s browser to this URL so they leave your site briefly. The query string tells Backboard which OAuth app this is (client_id), where to send the user afterward (redirect_uri), what data you want access to (scope), CSRF protection (state), and optionally PKCE (code_challenge / code_challenge_method). Backboard then shows consent in the browser, your server does not receive tokens from this GET request.

​
GET /api/oauth/authorize

Request parameters:

ParameterRequiredDescription
client_idYesYour OAuth App Key
redirect_uriYesWhere the user is sent after login. Must exactly match a URI you registered. Include localhost when testing locally.
response_typeNoMust be code (default in code is code).
scopeNoSpace-separated scopes (default openid). Must be a subset of the app’s registered scopes.
stateRecommendedOpaque value you generate; echoed back on success and error redirects so you can detect CSRF.
nonceNoOIDC nonce; use with openid when you need to bind the id_token to this login (stored on the auth code).
code_challengeWith PKCEPKCE challenge; use with code_challenge_method. See PKCE (Extra Security) and RFC 7636.
code_challenge_methodWith PKCEUse S256 with code_challenge.

Response

Success — HTTP 302

DestinationWhen
{FRONTEND}/oauth/consent?...Consent app loads; user signs in or continues, then approves so your app receives code on redirect_uri

Error — HTTP 302 to your redirect_uri with error, error_description, and state (OAuth-style redirect errors).

Error — HTTP 400 JSON body (no redirect), for example invalid or unknown client / malformed request in cases where redirect is not used:

import json

body = {"error": "invalid_client", "error_description": "..."}
print(json.dumps(body, indent=2))

Why there’s often no JSON: The authorization endpoint is defined around redirects (OAuth 2 / browser flows). A successful step returns 302 — meaning “open this next URL” (Backboard’s consent page). There is no { "success": true } body because the protocol expects the browser to follow Location, not your backend to parse JSON. The code appears later on your redirect_uri, only after the user approves (Step 2). 400 returns JSON when the request is invalid in a way that can’t be expressed as a redirect; some errors instead 302 back to your redirect_uri with error in the query string so the user returns to your app with an error state.

​
Step 2: Handle the Callback

After the user approves, Backboard redirects them to your redirect_uri with a code and state:

from urllib.parse import urlparse, parse_qs

# Example full URL the user's browser requests on your app
callback_url = "http://localhost:3000/auth/callback?code=Backboard_code_xxxxx&state=RANDOM_STRING"
query = parse_qs(urlparse(callback_url).query)
code = query["code"][0]
state = query["state"][0]

In your callback handler:

  1. Check that the state in the URL is the same random string you generated in Step 1. If it does not match, reject the request (this prevents attackers from forging login attempts)
  2. Take the code from the URL and exchange it for tokens by calling Backboard’s token endpoint (see Step 3 below)

​
Step 3: Exchange the Code for Tokens

Call this from your server only, never from the browser. Use POST /api/oauth/token with form data. Authenticate the OAuth app with client_id plus either client_secret (confidential) or code_verifier (public). See PKCE (Extra Security) for how code_verifier fits the full flow.

​
POST /api/oauth/token

Content-Type: application/x-www-form-urlencoded.

Client authentication: Send client_id. Also send client_secret (apps with a secret) or code_verifier (public apps, no secret).

PKCE: If /authorize used code_challenge, add code_verifier when you exchange the code. Do this even when you also send client_secret.

​
Authorization code (grant_type=authorization_code)

Request parameters

Form fieldRequiredDescription
grant_typeYesauthorization_code
codeYesAuthorization code from the redirect
redirect_uriYesSame value as in the authorize request
client_idYesOAuth App Key
client_secretOne of twoConfidential clients
code_verifierOne of two, or both with PKCEPublic clients: use instead of client_secret. Also required if /authorize sent code_challenge, even alongside client_secret

Response

Success — HTTP 200

FieldTypeDescription
access_tokenstringBearer access token
token_typestringBearer
expires_innumberAccess token lifetime in seconds
refresh_tokenstringRefresh token
scopestringGranted scopes
id_tokenstring | nullRS256 JWT when openid was granted; otherwise null
assistant_idstring | nullSee App mode below
backboard_api_keystring | nullSee App mode below
selected_client_namestring | nullThe organization the user signed in under, or null for their personal account

App mode (sso_mode)

Modeassistant_idbackboard_api_key
INDIVIDUALPersonal assistant id when provisioning succeedsPersonal API key when the server can return one (may be null if the account cannot receive a key)
ORGANIZATIONAssistant id for the user in that app’s orgAlways null (org billing and API access, not a personal key here)

Context selection

When a user belongs to one or more organizations, the consent step records which context they chose:

ValueMeaning
nullThe user’s personal account
An organization nameThat organization, matched among the user’s active memberships

A personal account is selected by passing null, never by name. Passing a personal client’s company name is rejected with invalid_request / "Invalid context selection".

Organization names are not globally unique, so a name is only ever matched against organizations the user is actually an active member of.

Error — HTTP 400 / 401

Invalid or expired code, PKCE mismatch, redirect_uri mismatch, unknown client, bad client_secret, missing code_verifier when PKCE requires it, and similar.

To rotate access tokens without another login, use grant_type=refresh_token on the same endpoint. See Step 5: Refresh the Token.

​
Examples

import requests

response = requests.post(
    "https://app.backboard.io/api/oauth/token",
    data={
        "grant_type": "authorization_code",
        "code": "Backboard_code_xxxxx",
        "redirect_uri": "http://localhost:3000/auth/callback",
        "client_id": "YOUR_CLIENT_ID",
        "client_secret": "YOUR_CLIENT_SECRET",
    }
)

tokens = response.json()
access_token = tokens["access_token"]
refresh_token = tokens["refresh_token"]

Sample JSON (authorization code grant)

import json

sample = {
    "access_token": "Backboard_at_xxxxx",
    "token_type": "Bearer",
    "expires_in": 3600,
    "refresh_token": "Backboard_rt_xxxxx",
    "scope": "openid email profile",
    "id_token": "eyJhbGciOi...",
    "assistant_id": "abc123-def456",
    "backboard_api_key": "espr_xxxxx",
}
print(json.dumps(sample, indent=2))

​
Step 4: Get User Info (optional)

You do not have to call this endpoint. If openid was granted, the id_token from Step 3 already contains sub and often other claims. You can also use fields returned on the token response (for example assistant_id) without calling userinfo. Use GET /api/oauth/userinfo when you want a standard profile document from the access token, or you do not want to parse the JWT.

​
GET /api/oauth/userinfo

InputDetails
Header AuthorizationRequired. Bearer <access_token>

Success — HTTP 200 — JSON. Claims depend on token scopes:

ScopeClaims
openidsub (required on success)
emailemail, email_verified
profilename, given_name, family_name, assistant_id (assistant resolved per app mode)

Fields absent for unrequested scopes are omitted or null.

Errors

StatusExample body
401{ "detail": "Bearer access token required" } or { "detail": "Invalid or expired access token" }
403{ "detail": "Token is missing the 'openid' scope required for /userinfo" }

​
Examples

user = requests.get(
    "https://app.backboard.io/api/oauth/userinfo",
    headers={"Authorization": f"Bearer {access_token}"}
).json()

print(user["email"])         # "alice@example.com"
print(user["name"])          # "Alice Smith"
print(user["sub"])           # stable Backboard account ID for this user
print(user["assistant_id"])  # Backboard assistant ID

​
Step 5: Refresh the Token

Access tokens expire (default: 1 hour). Call POST /api/oauth/token again with grant_type=refresh_token. Same Content-Type: application/x-www-form-urlencoded and the same client authentication rules as Step 3 (client_id plus client_secret or code_verifier).

​
Refresh token (grant_type=refresh_token)

Request parameters

Form fieldRequiredDescription
grant_typeYesrefresh_token
refresh_tokenYesCurrent refresh token
client_idYesOAuth App Key
client_secretOne ofConfidential clients
code_verifierOne ofPublic clients (authenticate without a secret)

Response

Success — HTTP 200

FieldTypeDescription
access_tokenstringNew bearer access token
token_typestringBearer
expires_innumberAccess token lifetime in seconds
refresh_tokenstringNew refresh token (rotation; replace your stored token)
scopestringGranted scopes

Do not rely on id_token, assistant_id, or backboard_api_key on refresh; they are not set by this grant (they may be absent or null in JSON).

Error — HTTP 400 / 401

Invalid or already-used refresh token, client auth failure, expired refresh token, unsupported grant_type, etc.

Only authorization_code exchanges return id_token, assistant_id, and backboard_api_key with meaningful values when applicable. refresh_token responses carry the five core token fields in the table above.

​
Examples

response = requests.post(
    "https://app.backboard.io/api/oauth/token",
    data={
        "grant_type": "refresh_token",
        "refresh_token": "Backboard_rt_xxxxx",
        "client_id": "YOUR_CLIENT_ID",
        "client_secret": "YOUR_CLIENT_SECRET",
    }
)

new_tokens = response.json()
new_access_token = new_tokens["access_token"]
new_refresh_token = new_tokens["refresh_token"]

Sample JSON (refresh)

import json

sample = {
    "access_token": "Backboard_at_xxxxx",
    "token_type": "Bearer",
    "expires_in": 3600,
    "refresh_token": "Backboard_rt_newxxxxx",
    "scope": "openid email profile",
}
print(json.dumps(sample, indent=2))

Use client_secret or code_verifier on this call, same rules as Step 3.

Each refresh token can only be used once. When you refresh, you get a new refresh token back. Always save the new one and discard the old one.

​
Quick reference

EndpointMethodPurpose
/api/oauth/authorizeGETStart login (redirect / consent UI)
/api/oauth/tokenPOSTCode exchange or refresh
/api/oauth/userinfoGETOIDC userinfo
/api/oauth/revokePOSTRevoke a token

​
PKCE (Extra Security)

PKCE (Proof Key for Code Exchange) is an extra layer of security that prevents attackers from stealing authorization codes. It is required for public clients (like mobile apps or single page apps) and recommended for all apps.

How it works:

  1. Before redirecting the user, generate a random code_verifier (a long random string)
  2. Hash it to create a code_challenge using SHA256
  3. Send the code_challenge in the authorize URL
  4. Send the code_verifier when exchanging the code for tokens
import hashlib
import base64
import secrets

# Generate verifier and challenge
code_verifier = secrets.token_urlsafe(32)
code_challenge = base64.urlsafe_b64encode(
    hashlib.sha256(code_verifier.encode()).digest()
).rstrip(b"=").decode()

# Use code_challenge in the authorize URL
# Use code_verifier in the token exchange (instead of client_secret)

When using PKCE, send code_verifier instead of client_secret in the token exchange:

import requests

response = requests.post(
    "https://app.backboard.io/api/oauth/token",
    data={
        "grant_type": "authorization_code",
        "code": "Backboard_code_xxxxx",
        "redirect_uri": "http://localhost:3000/auth/callback",
        "client_id": "YOUR_CLIENT_ID",
        "code_verifier": "YOUR_CODE_VERIFIER",
    },
)
tokens = response.json()

​
Revoking Tokens

If a user wants to disconnect your app, or you need to invalidate a token for security reasons:

​
POST /api/oauth/revoke

RFC 7009-style revocation. client_id and client_secret are both required` (revocation cannot be done with PKCE-only).

Form fieldRequiredDescription
tokenYesAccess or refresh token string to revoke
client_idYesOAuth App Key
client_secretYesApp secret
token_type_hintNoAccepted for compatibility; not required

Success 200 — empty JSON object:

import requests

response = requests.post(
    "https://app.backboard.io/api/oauth/revoke",
    data={
        "token": "Backboard_at_xxxxx",
        "client_id": "YOUR_CLIENT_ID",
        "client_secret": "YOUR_CLIENT_SECRET",
    },
)
assert response.json() == {}

Errors 401 — missing credentials or invalid client_id / client_secret.

This works for both access tokens and refresh tokens.

​
Common Mistakes