Bouée

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:

LevelPathWhat it coversWho can grant it
Account/api/v1/accountthe installation and its workspacesstaff
Workspace/api/v1/workspaces/{workspace id}tickets, conversations, members, the knowledge base, the audit logthe workspace's agents, never beyond their own role
User/api/v1/userthe person a token acts for: profile, workspaces, notificationsanyone

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

KindMade by, inActs asGrant
An app that acts for peoplestaff, in Instance settings → APIeach person who approves itauthorization code with PKCE, then refresh tokens
A workspace appthe workspace's owners, in its Settings → APIitself, in that workspaceclient credentials
An account appstaff, in Instance settings → APIitself, on the account APIclient 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

ScopeLevelAllows
account:readaccountreading the installation and its workspaces
account:writeaccountcreating workspaces (includes account:read)
workspace:readworkspacereading workspace details and brands
members:readworkspacereading who the workspace's agents are
tickets:readworkspacereading tickets and their conversations, internal notes included
tickets:writeworkspacecreating tickets, replying, adding notes, and changing status, priority, assignee and tags (includes tickets:read)
knowledge:readworkspacereading knowledge base articles; drafts for owners, and workspace apps
audit:readworkspacereading the workspace's audit log; for a person, only as an owner of it
user:readuserreading 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

  1. Send the person to the consent screen, with a PKCE challenge (S256; plain is refused) and a state:

    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
  2. They sign in if they need to, choose workspaces and approve. Their browser returns to the redirect URI with code, state and iss (RFC 9207: check it is APP_URL). A declined request returns error=access_denied.

  3. 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" }
  4. 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_credentials

It 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 in WWW-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": "…" }. Pass next_cursor back as cursor for the next page, until it is null; limit is 1–100, 50 by default.
  • Errors are { "error": { "code": "…", "message": "…" } }: 400 invalid_request, 401 unauthorized or invalid_token, 403 insufficient_scope (the scope needed is in WWW-Authenticate) or forbidden, 404 not_found (also for a workspace the token cannot reach, so a token cannot learn which exist), 409 conflict, 422 invalid_request, 429 rate_limited with Retry-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/userthe person the token acts foruser:read
GET /api/v1/user/workspacesthe workspaces the token may use for them, with their roleuser:read
GET /api/v1/user/notificationstheir notifications there, newest first; unread=trueuser:read

An app acting as itself has no person, and gets 403 here.

Account

Scope
GET /api/v1/accountthe installation's address, edition, version and number of workspacesaccount:read
GET /api/v1/account/workspacesevery workspaceaccount:read
POST /api/v1/account/workspacesa 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 addressesworkspace:read
GET /api/v1/workspaces/{id}/membersits agents and their rolesmembers:read
GET /api/v1/workspaces/{id}/ticketstickets, newest first; status (comma-separated), assignee_id (or none), updated_aftertickets:read
POST /api/v1/workspaces/{id}/ticketsa new tickettickets:write
GET /api/v1/workspaces/{id}/tickets/{number}one tickettickets:read
PATCH /api/v1/workspaces/{id}/tickets/{number}status, priority, assignee_id (null unassigns), add_tags, remove_tagstickets:write
GET /api/v1/workspaces/{id}/tickets/{number}/messagesthe conversation, oldest first, internal notes includedtickets:read
POST /api/v1/workspaces/{id}/tickets/{number}/messagesa reply, emailed to the requester, or with "internal": true a notetickets:write
GET /api/v1/workspaces/{id}/knowledge/articlesarticles, newest change first; drafts for owners and workspace apps; brand_idknowledge:read
GET /api/v1/workspaces/{id}/knowledge/articles/{article id}one article, with its Markdownknowledge:read
GET /api/v1/workspaces/{id}/audit-eventsthe audit log, newest first; owners, and workspace appsaudit: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 as Authorization or X-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.

On this page