---
title: "OAuth for apps"
description: "Apps of other vendors connect to Bodo through OAuth 2.1: authorization code with PKCE, short access tokens, rotating refresh tokens and resource indicators."
lang: en
url: https://developers.bodo-app.com/en/guides/oauth/
apiVersion: 2026-11-01
---

# OAuth for apps

Apps of other vendors connect to Bodo through OAuth 2.1: authorization code with PKCE, short access tokens, rotating refresh tokens and resource indicators.

> **Who decides** The user consents, no admin approves apps. Your app never gets more than the consenting user may do, and only while the API of their organization is on. When the organization switches the API off, all app tokens there end.

## The flow

Ten messages between four parties. Your app reads the addresses from `/.well-known/oauth-authorization-server`.

1. Your app → Bodo: sign-in and consent: redirect to /oauth/authorize with PKCE (S256) and resource
2. User in the browser → Bodo: sign-in and consent: signs in and picks the organization
3. Bodo: sign-in and consent → User in the browser: shows the consent window: what the app wants to read and change
4. User in the browser → Bodo: sign-in and consent: allows
5. Bodo: sign-in and consent → Your app: back to the redirect_uri with code and state
6. Your app → Bodo: sign-in and consent: POST /oauth/token with code and code_verifier
7. Bodo: sign-in and consent → Your app: access token (10 min) and refresh token
8. Your app → Bodo API: GET /v1/… with Authorization: Bearer
9. Bodo API → Your app: answer with the user’s rights, intersected with the consent
10. Your app → Bodo: sign-in and consent: refresh: a new refresh token, the old one is invalid

later, without the user: the refresh token expires after 30 days unused, the consent after 365 days

## 1 · Register the app

In Bodo under [Settings › Interfaces › OAuth apps](https://bodo-app.com/settings/oauth-apps): name, logo, redirect URI. You get a `client_id`. Public clients (browser, mobile) need no secret, PKCE is required.

| Field | Example |
| --- | --- |
| Name | Muster CRM-Sync |
| Redirect URI | https://app.musterfirma.de/oauth/callback |
| client_id | app_7Hq2… |

## 2 · Send the user to consent

With PKCE (S256) and `resource`, so the token is only valid for the API.

```http
GET https://api.bodo-app.com/oauth/authorize
  ?response_type=code
  &client_id=app_7Hq2…
  &redirect_uri=https://app.musterfirma.de/oauth/callback
  &scope=contacts:read tasks:write offline_access
  &state=9f2c…
  &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
  &code_challenge_method=S256
  &resource=https://api.bodo-app.com
```

## What the user sees

**What the user sees in the consent window**

> **Muster CRM-Sync wants to access Bodo**
> Signed in as Erika Mustermann · Musterfirma GmbH
>
> - Read: Contacts
> - Change: Create, change and complete tasks
> - Lasting: Access stays until you revoke it (offline_access)
>
> The app never gets more than you may do yourself. You can revoke it at any time under Profile › Connected apps.
> Below it the buttons Deny and Allow.

## 3 · Trade the code for tokens

With the `code_verifier` from step 2. The access token is valid for 10 minutes.

```http
POST https://api.bodo-app.com/oauth/token
grant_type=authorization_code&code=…&code_verifier=…
&client_id=app_7Hq2…&resource=https://api.bodo-app.com

{
  "access_token": "eyJhbGciOiJFUzI1NiIs…",
  "token_type": "Bearer",
  "expires_in": 600,
  "refresh_token": "bodo_rt_8Vd1…",
  "scope": "contacts:read tasks:write offline_access"
}
```

## 4 · Refresh with rotation

Every refresh returns a new refresh token; the old one is invalid from then on.

| Case | Consequence |
| --- | --- |
| **Correct** | store the new refresh token at once, drop the old one |
| **Reuse** | an old refresh token used again: Bodo revokes the whole chain, the user has to consent again |
| **Expiry** | a refresh token unused for 30 days expires; the consent itself after 365 days |

## With the SDK

The SDKs build PKCE, trade the code and refresh the token before it expires. You only store the latest refresh token.

TypeScript · SDK:

```typescript
// Portal D12 · OAuth — PKCE, consent, code exchange and refresh with rotation.
// Run: BODO_CLIENT_ID=bodo_ci_… BODO_CLIENT_SECRET=… bun examples/oauth.ts
import { Bodo } from "@bodo/api";
import {
  buildAuthorizeUrl,
  createPkcePair,
  exchangeCode,
  OAuthSession,
  OFFLINE_ACCESS_SCOPE,
} from "@bodo/api/oauth";

const clientId = process.env.BODO_CLIENT_ID ?? "";
const clientSecret = process.env.BODO_CLIENT_SECRET;
const redirectUri = "https://crm.musterfirma.de/oauth/callback";

// Step 2 · send the user to the consent page
const pkce = await createPkcePair();
const state = crypto.randomUUID();
console.log(
  buildAuthorizeUrl({
    clientId,
    redirectUri,
    scope: ["contacts:read", "contacts:write", OFFLINE_ACCESS_SCOPE],
    state,
    codeChallenge: pkce.challenge,
  }),
);

// Step 3 · your callback receives ?code=…&state=… — compare state, then trade the code
const code = process.env.BODO_OAUTH_CODE ?? "";
const tokens = await exchangeCode({
  clientId,
  clientSecret,
  code,
  codeVerifier: pkce.verifier,
  redirectUri,
});

// Step 4 · one session per grant: it refreshes before expiry and rotates the refresh token
const session = new OAuthSession({
  clientId,
  clientSecret,
  tokens,
  onTokens: (next) => {
    // persist next.refreshToken — the previous one is invalid from now on
    console.log("rotated, valid until", new Date(next.expiresAt).toISOString());
  },
});

const bodo = new Bodo({ oauth: session });
const me = await bodo.me();
console.log(me.authMethod, me.principalType);
```

Python · SDK:

```python
# PKCE, consent, code exchange and refresh with rotation.
# Run: BODO_CLIENT_ID=bodo_ci_… BODO_CLIENT_SECRET=… python examples/oauth.py
import os
import secrets

from bodo_api import Bodo, OAuthSession, build_authorize_url, create_pkce_pair, exchange_code
from bodo_api.oauth import OFFLINE_ACCESS_SCOPE

client_id = os.environ["BODO_CLIENT_ID"]
client_secret = os.environ.get("BODO_CLIENT_SECRET")
redirect_uri = "https://crm.musterfirma.de/oauth/callback"

# Step 2 · send the user to the consent page
pkce = create_pkce_pair()
state = secrets.token_urlsafe(16)
print(
    build_authorize_url(
        client_id=client_id,
        redirect_uri=redirect_uri,
        scope=["contacts:read", "contacts:write", OFFLINE_ACCESS_SCOPE],
        state=state,
        code_challenge=pkce.challenge,
    )
)

# Step 3 · your callback receives ?code=…&state=… — compare state, then trade the code
tokens = exchange_code(
    client_id=client_id,
    client_secret=client_secret,
    code=os.environ["BODO_OAUTH_CODE"],
    code_verifier=pkce.verifier,
    redirect_uri=redirect_uri,
)

# Step 4 · one session per grant: it refreshes before expiry and rotates the refresh token
session = OAuthSession.from_tokens(
    client_id,
    tokens,
    client_secret=client_secret,
    # persist tokens.refresh_token here — the previous one is invalid from now on
    on_tokens=lambda rotated: print("rotated, valid until", rotated.expires_at),
)

with Bodo(oauth=session) as bodo:
    me = bodo.me()
    print(me.auth_method, me.principal_type)
```

## Scopes

The same rights as for a key, intersected with the rights of the user.

| Scope | Allows |
| --- | --- |
| contacts · companies · tasks · documents | `:read` or `:write` |
| invoices · time_entries | `:read` or `:write`, money actions only under the user’s AI mandate |
| webhook_endpoints:write | manage the user’s webhook endpoints |
| offline_access | refresh token for access while the user is away |

The API rejects a token for another `resource` with [401 UNAUTHENTICATED](/en/problems/UNAUTHENTICATED/). Deleting and the money families only work when the user ticks them one by one.
