# Connect with OAuth

> Connect an org admin's BloomText account to your app with the OAuth 2.0 authorization code flow and PKCE, then refresh tokens and handle revocation.

Source: https://www.bloomtext.com/developers/api/oauth/

OAuth lets an org admin connect your app to their BloomText account. Your app then acts as that admin: it reads and searches their conversations, sends in the ones they're part of, and makes the same patient calls as an API key.

For now, only org admins can connect apps. Anyone else is sent back to your redirect URI with `error=access_denied`.

[Step through the flow](#the-authorization-flow) to see every request, who makes it, and how long each token lasts.

## Before you start

Only apps registered with BloomText can connect. [Request API access](https://calendly.com/tyler-bloom/bloomtext-homepage-demo-request?utm_campaign=api-access) to register yours.

### We register your app

After your organization signs a BAA, we register your app and send you its client ID and client secret. Tell us the name users should see and every redirect URI your app uses. Redirect URIs must match exactly and use HTTPS; `http://localhost` URIs are allowed for development.

### An admin approves your app

A BloomText admin in each organization approves your app before anyone there can connect it. Until then, people in that organization skip the approval screen and go straight back to your redirect URI with `error=access_denied`.

### Admins connect one at a time

Each org admin signs in and approves your app themselves, and your app keeps a separate token for each.

> **Warning:** Keep the client secret on your server. Never put it in a browser, a mobile app, a public repository, or an AI prompt.

## The authorization flow

BloomText uses the authorization code flow with PKCE. PKCE is required for every app, including server-side apps with a client secret. Click through the flow, or read the steps below.

### Send the user to BloomText

Create a random `state` and a PKCE `code_verifier`, store both with the user's session, and redirect the user to the authorize URL.

```text filename="Authorize URL"
https://app.bloomtext.com/oauth/authorize
  ?response_type=code
  &client_id=bt_client_7Hq2mX9aLk
  &redirect_uri=https://ehr.example.com/bloomtext/callback
  &scope=messages.read offline_access
  &state=af0ifjsldkj
  &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
  &code_challenge_method=S256
```

- `response_type` (string, required): Always `code`.

- `client_id` (string, required): Your app's client ID.

- `redirect_uri` (URL, required): Where BloomText sends the user back. Must exactly match a redirect URI registered for your app.

- `scope` (string, required): Space-separated [scopes](https://www.bloomtext.com/developers/api/authentication/#scopes). Include `offline_access` to get a refresh token.

- `state` (string, required): A random value you check on the way back, to protect against cross-site request forgery.

- `code_challenge` (string, required): The base64url-encoded SHA-256 hash of your `code_verifier`.

- `code_challenge_method` (string, required): Always `S256`.

### The user signs in and approves

BloomText hosts the sign-in and approval pages, so users sign in with their usual password and multi-factor authentication. A user who is already signed in sees only the approval screen, which names your app, the user's organization, and what each requested scope allows.

### Handle the redirect

BloomText sends the user back to your redirect URI:

```text filename="Redirect"
https://ehr.example.com/bloomtext/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=af0ifjsldkj
```

Check that `state` matches what you stored, then exchange the code. Codes expire after 10 minutes and work once.

If the user declines, isn't an org admin, or their organization hasn't approved your app, the redirect carries `error=access_denied` and an `error_description` instead of a code. The one exception is a bad `client_id` or an unregistered `redirect_uri`: as OAuth requires, BloomText shows an error page instead of redirecting.

### Exchange the code for tokens

Call the token endpoint from your server, authenticating with your client ID and secret.

```bash filename="Request"
curl https://app.bloomtext.com/oauth/token \
  -u "$BLOOMTEXT_CLIENT_ID:$BLOOMTEXT_CLIENT_SECRET" \
  -d grant_type=authorization_code \
  -d code=SplxlOBeZQQYbYS6WxSbIA \
  -d redirect_uri=https://ehr.example.com/bloomtext/callback \
  -d code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
```

```js filename="oauth.mjs"
import crypto from 'node:crypto'

const AUTHORIZE_URL = 'https://app.bloomtext.com/oauth/authorize'
const TOKEN_URL = 'https://app.bloomtext.com/oauth/token'
const REDIRECT_URI = 'https://ehr.example.com/bloomtext/callback'

// Step 1: build the authorize URL. Store state and verifier with the session.
export function startAuthorization(session) {
  session.state = crypto.randomBytes(16).toString('base64url')
  session.verifier = crypto.randomBytes(32).toString('base64url')
  const challenge = crypto.createHash('sha256').update(session.verifier).digest('base64url')

  const url = new URL(AUTHORIZE_URL)
  url.search = new URLSearchParams({
    response_type: 'code',
    client_id: process.env.BLOOMTEXT_CLIENT_ID,
    redirect_uri: REDIRECT_URI,
    scope: 'messages.read offline_access',
    state: session.state,
    code_challenge: challenge,
    code_challenge_method: 'S256',
  })
  return url.toString()
}

// Step 4: exchange the code from the redirect for tokens.
export async function finishAuthorization(session, query) {
  if (query.error) throw new Error(`Not connected: ${query.error_description ?? query.error}`)
  if (query.state !== session.state) throw new Error('State mismatch')

  const credentials = Buffer.from(`${process.env.BLOOMTEXT_CLIENT_ID}:${process.env.BLOOMTEXT_CLIENT_SECRET}`).toString('base64')
  const response = await fetch(TOKEN_URL, {
    method: 'POST',
    headers: { Authorization: `Basic ${credentials}` },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      code: query.code,
      redirect_uri: REDIRECT_URI,
      code_verifier: session.verifier,
    }),
  })
  if (!response.ok) throw new Error(`Token exchange failed: ${response.status}`)
  return response.json() // { access_token, refresh_token, expires_in, scope, token_type }
}
```

```python filename="oauth.py"
import base64
import hashlib
import os
import secrets
from urllib.parse import urlencode

import requests

AUTHORIZE_URL = "https://app.bloomtext.com/oauth/authorize"
TOKEN_URL = "https://app.bloomtext.com/oauth/token"
REDIRECT_URI = "https://ehr.example.com/bloomtext/callback"


def start_authorization(session: dict) -> str:
    """Step 1: build the authorize URL. Store state and verifier with the session."""
    session["state"] = secrets.token_urlsafe(16)
    session["verifier"] = secrets.token_urlsafe(32)
    digest = hashlib.sha256(session["verifier"].encode()).digest()
    challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()

    return AUTHORIZE_URL + "?" + urlencode({
        "response_type": "code",
        "client_id": os.environ["BLOOMTEXT_CLIENT_ID"],
        "redirect_uri": REDIRECT_URI,
        "scope": "messages.read offline_access",
        "state": session["state"],
        "code_challenge": challenge,
        "code_challenge_method": "S256",
    })


def finish_authorization(session: dict, query: dict) -> dict:
    """Step 4: exchange the code from the redirect for tokens."""
    if "error" in query:
        raise RuntimeError(f"Not connected: {query.get('error_description', query['error'])}")
    if query["state"] != session["state"]:
        raise RuntimeError("State mismatch")

    response = requests.post(
        TOKEN_URL,
        auth=(os.environ["BLOOMTEXT_CLIENT_ID"], os.environ["BLOOMTEXT_CLIENT_SECRET"]),
        data={
            "grant_type": "authorization_code",
            "code": query["code"],
            "redirect_uri": REDIRECT_URI,
            "code_verifier": session["verifier"],
        },
    )
    response.raise_for_status()
    return response.json()  # access_token, refresh_token, expires_in, scope, token_type
```

```json filename="Response · 200 OK"
{
  "access_token": "bt_at_Vq8yG2mN0cT4…",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "bt_rt_k3Jd9Qp1Xw7s…",
  "scope": "messages.read offline_access"
}
```

Store the refresh token against the user, encrypted, the same way you store PHI.

### Call the API

Send the access token as a Bearer token. [Retrieve the profile](https://www.bloomtext.com/developers/api/reference/get-profile/) to confirm which user connected.

```bash filename="Request"
curl https://api.bloomtext.com/v1/me \
  -H "Authorization: Bearer $BLOOMTEXT_ACCESS_TOKEN"
```

```json filename="Response · 200 OK"
{
  "user": { "id": "8b9c2d2f-6b1a-44f4-a7b1-0d6d3f2d6f55", "name": "Alex Example", "role": "admin" },
  "organization": { "id": "6db1e3f5-9b7f-4f2b-8be1-0f1e1d7d7d8c", "name": "Example Clinic" },
  "app": { "client_id": "bt_client_7Hq2mX9aLk", "name": "Acme EHR Assistant" },
  "scopes": ["messages.read", "offline_access"],
  "expires_at": "2027-09-21T15:04:05Z"
}
```

## What a token can reach

An OAuth token acts as the org admin who connected it. It reaches:

- the admin's own conversations, whether they were added directly or through a team, including messages sent before they joined, but no one else's staff conversations;
- patients, always named by patient ID, phone number, or MRN, with all of that patient's conversations in the organization, like an API key;
- the clinic phone numbers of the teams the admin is on.

Anything else returns `404`, the same as something that doesn't exist. Access lasts only while the admin has it: removing them from a conversation, or taking away their admin role, cuts off the token on the next request.

Messages your app sends appear under the admin's name. Staff also see which app sent them; patients see only the admin. See [Core concepts](https://www.bloomtext.com/developers/api/concepts/#messages-sent-through-the-api).

## Tokens and how long they last

| Token | Lifetime | Notes |
| --- | --- | --- |
| Authorization code | 10 minutes | Works once. |
| Access token | 1 hour | Send it with every request. `expires_in` says how many seconds it has left. |
| Refresh token | Rotates on every use | Issued only with `offline_access`. Stops working after 90 days unused. |
| The connection | 12 months | After 12 months the user must approve your app again, even if you use it every day. [`expires_at`](https://www.bloomtext.com/developers/api/reference/profile-object/) on the profile says when. |

The user connects once and your app keeps working for up to a year: refresh the access token when it expires, and ask the user to reconnect when `expires_at` is near.

### Refresh an access token

```bash filename="Request"
curl https://app.bloomtext.com/oauth/token \
  -u "$BLOOMTEXT_CLIENT_ID:$BLOOMTEXT_CLIENT_SECRET" \
  -d grant_type=refresh_token \
  -d refresh_token=bt_rt_k3Jd9Qp1Xw7s…
```

```js filename="refresh.mjs"
export async function refresh(refreshToken) {
  const credentials = Buffer.from(`${process.env.BLOOMTEXT_CLIENT_ID}:${process.env.BLOOMTEXT_CLIENT_SECRET}`).toString('base64')
  const response = await fetch('https://app.bloomtext.com/oauth/token', {
    method: 'POST',
    headers: { Authorization: `Basic ${credentials}` },
    body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: refreshToken }),
  })
  if (response.status === 400) {
    const { error } = await response.json()
    if (error === 'invalid_grant') return null // the user must reconnect
    throw new Error(`Refresh failed: ${error}`)
  }
  if (!response.ok) throw new Error(`Refresh failed: ${response.status}`)
  return response.json() // save the new refresh_token before using the access token
}
```

```python filename="refresh.py"
import os

import requests


def refresh(refresh_token: str) -> dict | None:
    response = requests.post(
        "https://app.bloomtext.com/oauth/token",
        auth=(os.environ["BLOOMTEXT_CLIENT_ID"], os.environ["BLOOMTEXT_CLIENT_SECRET"]),
        data={"grant_type": "refresh_token", "refresh_token": refresh_token},
    )
    if response.status_code == 400:
        error = response.json()["error"]
        if error == "invalid_grant":
            return None  # the user must reconnect
        raise RuntimeError(f"Refresh failed: {error}")
    response.raise_for_status()
    return response.json()  # save the new refresh_token before using the access token
```

Every refresh returns a new refresh token and invalidates the old one. Save the new one before you do anything else.

If a refresh request times out and you don't know whether it worked, retry with the same refresh token within 30 seconds. BloomText returns the tokens it issued the first time.

> **Warning:** Using an old refresh token after that 30-second window revokes the whole connection, because it usually means the token was copied. If two workers can refresh the same user's token, make them share a lock.

## When a connection ends

A connection stops working when:

- the user disconnects your app from **Settings → Connected apps** in BloomText;
- an admin revokes your app for their organization;
- the admin is deactivated, loses their admin role, leaves the organization, or resets their password;
- your app goes 90 days without refreshing;
- 12 months pass since the user approved your app.

After that, API calls return `401` with `code: invalid_token`, and refreshing returns `400` with `error: invalid_grant`. Show the user a "Reconnect BloomText" button that starts the flow again.

To disconnect a user from your side, for example when they turn off the integration, revoke the refresh token:

```bash filename="Request"
curl https://app.bloomtext.com/oauth/revoke \
  -u "$BLOOMTEXT_CLIENT_ID:$BLOOMTEXT_CLIENT_SECRET" \
  -d token=bt_rt_k3Jd9Qp1Xw7s…
```

## Token endpoint errors

The token endpoint returns standard OAuth errors as JSON: `{"error": "invalid_grant", "error_description": "…"}`.

| Error | Status | What to do |
| --- | --- | --- |
| `invalid_grant` | 400 | The code or refresh token is expired, used, or revoked, or the `code_verifier` or `redirect_uri` doesn't match the authorization request. For an expired or revoked token, send the user through the authorize URL again. |
| `invalid_client` | 401 | Check your client ID and secret. |
| `invalid_request` | 400 | A required parameter is missing, repeated, or malformed. |
| `invalid_scope` | 400 | A refresh asked for a scope the user didn't grant. Refresh with the same or fewer scopes. |
| `unauthorized_client` | 400 | Your app isn't allowed to use this grant type. Contact us. |
| `unsupported_grant_type` | 400 | Use `authorization_code` or `refresh_token`. |

API endpoints use [problem details](https://www.bloomtext.com/developers/api/errors/) instead. `401` and `403` responses also carry a `WWW-Authenticate` header, like `Bearer error="invalid_token", resource_metadata="https://api.bloomtext.com/.well-known/oauth-protected-resource/v1"`.

## Server metadata

OAuth libraries can configure themselves from these discovery documents.

| Document | URL |
| --- | --- |
| Authorization server metadata ([RFC 8414](https://www.rfc-editor.org/rfc/rfc8414)) | `https://app.bloomtext.com/.well-known/oauth-authorization-server` |
| Protected resource metadata ([RFC 9728](https://www.rfc-editor.org/rfc/rfc9728)) for `https://api.bloomtext.com/v1` | `https://api.bloomtext.com/.well-known/oauth-protected-resource/v1` |
