API
The account, workspace and user APIs, which apps reach by OAuth alone.
Bouée's API is at APP_URL/api/v1, in three levels, as Cloudflare's is:
| Level | Path | What it covers | Who can grant it |
|---|---|---|---|
| Account | /api/v1/account | the installation and its workspaces | staff |
| Workspace | /api/v1/workspaces/{workspace id} | tickets, conversations, members, the knowledge base, the audit log | the workspace's agents, never beyond their own role |
| User | /api/v1/user | the person a token acts for: profile, workspaces, notifications | anyone |
Apps reach it by OAuth alone. There are no API keys, personal access tokens, basic authentication or session cookies used as bearer tokens, and none are to be added: a key is a password that never expires, gets pasted into scripts and chat, and cannot say which app is using it or be limited to what that app needs.
Apps
| Kind | Made by, in | Acts as | Grant |
|---|---|---|---|
| An app that acts for people | staff, in Instance settings → API | each person who approves it | authorization code with PKCE, then refresh tokens |
| A workspace app | the workspace's owners, in its Settings → API | itself, in that workspace | client credentials |
| An account app | staff, in Instance settings → API | itself, on the account API | client credentials |
An app that acts for people is either public, with no secret (a command-line tool or a desktop app, which proves itself with PKCE alone), or has a client secret (a server). Its redirect URIs are https, or http on 127.0.0.1 or [::1] for an app on the person's own computer, whose port may change between runs (RFC 8252). The authorization returns only to a registered URI.
A person approves an app on the consent screen at APP_URL/oauth/authorize, choosing which of their workspaces it may use. Their latest approval is what the app has. They see and revoke their approvals under Connected apps in the user menu (/user/apps), a workspace's owners see which apps people connected to their workspace and can take their workspace out of an approval, and every approval is recorded in the audit log of each workspace it names.
A token never does more than its person can at the time of each request: an agent who leaves a workspace takes the apps they approved out of it, a blocked person's tokens stop working, and account scopes stop working when someone stops being staff.
A workspace app's or account app's secret is shown once, when it is made or replaced. Replacing it, or deleting the app, ends every token it holds.
Either can also be declared in bouee.yaml, with its secret read from the environment, so an integration can be
configured before Bouée starts (Automated install).
Scopes
| Scope | Level | Allows |
|---|---|---|
account:read | account | reading the installation and its workspaces |
account:write | account | creating workspaces (includes account:read) |
workspace:read | workspace | reading workspace details and brands |
members:read | workspace | reading who the workspace's agents are |
tickets:read | workspace | reading tickets and their conversations, internal notes included |
tickets:write | workspace | creating tickets, replying, adding notes, and changing status, priority, assignee and tags (includes tickets:read) |
knowledge:read | workspace | reading knowledge base articles; drafts for owners, and workspace apps |
audit:read | workspace | reading the workspace's audit log; for a person, only as an owner of it |
user:read | user | reading the person's profile, workspaces and notifications |
A workspace app may hold workspace scopes only, and an account app account scopes only: staff cannot read a workspace's tickets through the account API, just as the staff role grants no access to them in the console.
An app that acts for people
-
Send the person to the consent screen, with a PKCE challenge (S256;
plainis refused) and astate:https://support.example.com/oauth/authorize?response_type=code&client_id=bcl_…&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback&scope=tickets%3Awrite%20user%3Aread&state=…&code_challenge=…&code_challenge_method=S256 -
They sign in if they need to, choose workspaces and approve. Their browser returns to the redirect URI with
code,stateandiss(RFC 9207: check it isAPP_URL). A declined request returnserror=access_denied. -
Exchange the code within five minutes, once:
curl -s https://support.example.com/oauth/token \ -u "bcl_…:bcs_…" \ -d grant_type=authorization_code -d code=bac_… -d redirect_uri=https://app.example.com/callback -d code_verifier=…A public app sends
-d client_id=bcl_…instead of-u. The answer:{ "access_token": "bat_…", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "brt_…", "scope": "tickets:write user:read" } -
Refresh before the hour is up with
grant_type=refresh_token&refresh_token=brt_…. Every refresh returns a new refresh token and retires the old one. Presenting a retired one again ends the whole chain, because it means someone else has a copy; a retry within ten seconds is only refused. Refresh tokens last 30 days from their issue.
An app that acts as itself
curl -s https://support.example.com/oauth/token -u "bcl_…:bcs_…" -d grant_type=client_credentialsIt gets an access token for all its scopes, or for fewer with -d scope=…, and no refresh token: ask again when it expires. A workspace app acts in its own workspace; its replies and notes carry its name.
Revoking
POST /oauth/revoke with token=… and the app's credentials (RFC 7009) ends that token, and a refresh token its whole chain. It answers 200 whatever the token was.
Discovery
GET /.well-known/oauth-authorization-server(RFC 8414): the endpoints, scopes, grants and methods.GET /.well-known/oauth-protected-resource(RFC 9728): the API's resource and its authorization server. A 401 names it inWWW-Authenticate.
Calling the API
Send the access token in the Authorization header, and nowhere else:
curl -s https://support.example.com/api/v1/user -H "Authorization: Bearer bat_…"- Bodies are JSON (
Content-Type: application/json), at most 256 KB of message text. - Answers are
{ "data": … }; lists are{ "data": [ … ], "next_cursor": "…" }. Passnext_cursorback ascursorfor the next page, until it isnull;limitis 1–100, 50 by default. - Errors are
{ "error": { "code": "…", "message": "…" } }: 400invalid_request, 401unauthorizedorinvalid_token, 403insufficient_scope(the scope needed is inWWW-Authenticate) orforbidden, 404not_found(also for a workspace the token cannot reach, so a token cannot learn which exist), 409conflict, 422invalid_request, 429rate_limitedwithRetry-After. - Keys are snake_case, times ISO 8601, and statuses and priorities lower case (
new,open,pending,resolved,closed;low,normal,high,urgent). - Rate: 1,200 requests a minute for each person's approval of an app, or for each app acting as itself, and 120 token requests a minute per address.
Endpoints
User
| Scope | ||
|---|---|---|
GET /api/v1/user | the person the token acts for | user:read |
GET /api/v1/user/workspaces | the workspaces the token may use for them, with their role | user:read |
GET /api/v1/user/notifications | their notifications there, newest first; unread=true | user:read |
An app acting as itself has no person, and gets 403 here.
Account
| Scope | ||
|---|---|---|
GET /api/v1/account | the installation's address, edition, version and number of workspaces | account:read |
GET /api/v1/account/workspaces | every workspace | account:read |
POST /api/v1/account/workspaces | a new workspace: { "name", "owner_email"? } | account:write |
A new workspace's owner is owner_email, someone who has signed in before, or the staff member the token acts for. An account app naming nobody leaves it without an owner, for staff to take over in Instance settings.
Workspace
| Scope | ||
|---|---|---|
GET /api/v1/workspaces/{id} | the workspace and its brands, with each brand's help center and portal addresses | workspace:read |
GET /api/v1/workspaces/{id}/members | its agents and their roles | members:read |
GET /api/v1/workspaces/{id}/tickets | tickets, newest first; status (comma-separated), assignee_id (or none), updated_after | tickets:read |
POST /api/v1/workspaces/{id}/tickets | a new ticket | tickets:write |
GET /api/v1/workspaces/{id}/tickets/{number} | one ticket | tickets:read |
PATCH /api/v1/workspaces/{id}/tickets/{number} | status, priority, assignee_id (null unassigns), add_tags, remove_tags | tickets:write |
GET /api/v1/workspaces/{id}/tickets/{number}/messages | the conversation, oldest first, internal notes included | tickets:read |
POST /api/v1/workspaces/{id}/tickets/{number}/messages | a reply, emailed to the requester, or with "internal": true a note | tickets:write |
GET /api/v1/workspaces/{id}/knowledge/articles | articles, newest change first; drafts for owners and workspace apps; brand_id | knowledge:read |
GET /api/v1/workspaces/{id}/knowledge/articles/{article id} | one article, with its Markdown | knowledge:read |
GET /api/v1/workspaces/{id}/audit-events | the audit log, newest first; owners, and workspace apps | audit:read |
Creating a ticket:
{
"subject": "Printer on floor 3 is jammed",
"requester": { "email": "jo@example.com", "name": "Jo" },
"priority": "high",
"tags": ["printers"],
"message": { "body": "It shows error E52.", "from": "requester" }
}message.from is requester for what the customer wrote, as a ticket that arrived by email would hold (the team is notified and the requester gets the usual acknowledgment), or team for a reply to the requester, or with "internal": true a note. Ticket writes follow the console's rules: a reply to a closed ticket is refused, Resolved and Closed wait for the workspace's required fields, and an agent's first reply makes them the owner of an unassigned ticket (an app is never made one).
What keeps it OAuth-only
src/lib/__tests__/api-oauth-only.test.ts fails when:
- a Better Auth plugin that issues keys or accepts sessions as bearer tokens (
apiKey,bearer,oneTimeToken) is imported, or an API-key package is added; - anything but the API's bearer check (
src/lib/api/auth.ts) and the token endpoint's client authentication (src/lib/oauth/clients.ts) reads a credential header such asAuthorizationorX-API-Key; - the schema gains an API key or personal token table.
Secrets, codes and tokens are stored only as SHA-256 hashes, and carry a prefix (bcs_, bac_, bat_, brt_) so a leaked one says what it is. oauth-postgres.test.ts runs the flows against PostgreSQL (Testing (docs/testing.md in the app repository)).
Webhook signing secrets, such as the one Resend signs incoming email with, are not API credentials: they prove a delivery came from the provider, and grant nothing.