OAuth
OAuth 2.1 authorization-code flow, PKCE, scopes, discovery, and client registration
Alongside API keys, Servicebay supports OAuth 2.1 so a third-party app or MCP agent can be authorized by a Servicebay admin without ever handling their password or a long-lived key.
An organisation admin signs in, picks which organisation to connect, and approves exactly the permissions your app asked for — or fewer. Your app receives tokens scoped to that organisation and nothing else.
The authorization server
The authorization server lives at https://oauth.servicebay.io. Discovery:
GET https://oauth.servicebay.io/.well-known/oauth-authorization-server| Endpoint | Path |
|---|---|
| Authorization | /authorize |
| Token | /token |
| Dynamic client registration (RFC 7591) | /register |
| Introspection | Internal only (POST /introspect with X-Internal-Service) |
Authorization responses include iss=https://oauth.servicebay.io (RFC 9207).
Validate it.
PKCE S256 is mandatory for every client, including confidential ones.
code_challenge_method=plain and the implicit flow are rejected.
code_challenge must be the 43-character base64url SHA-256 of the verifier.
Resource and audience
resource is optional on authorize and token requests.
| Value | Result |
|---|---|
| Omitted | Canonicalised to https://oauth.servicebay.io/mcp |
https://oauth.servicebay.io/mcp | Accepted |
https://developer.servicebay.io | Accepted as an alias, rewritten to the MCP resource |
https://developer.servicebay.io/api/v1 | Accepted as an alias, rewritten to the MCP resource |
| Anything else | invalid_target |
Every access token's aud is https://oauth.servicebay.io/mcp. The
authorization server pins the resource metadata to the MCP endpoint so one
token works for both MCP and REST.
The REST API (https://developer.servicebay.io/api/v1) accepts that
MCP audience — plus REST origin aliases and legacy unbound tokens. Send
the same Bearer token as Authorization: Bearer on /api/v1/**. You do
not need a second token for REST.
Protected-resource metadata (RFC 9728):
- REST:
https://developer.servicebay.io/.well-known/oauth-protected-resource - REST API:
https://developer.servicebay.io/.well-known/oauth-protected-resource/api/v1 - MCP:
https://oauth.servicebay.io/.well-known/oauth-protected-resource/mcp
REST 401 responses include:
WWW-Authenticate: Bearer error="invalid_token", resource_metadata="https://developer.servicebay.io/.well-known/oauth-protected-resource"Authorization-code flow
Redirect the user to /authorize
https://oauth.servicebay.io/authorize
?response_type=code
&client_id=YOUR_CLIENT_ID
&redirect_uri=https://example.com/oauth/callback
&scope=tickets:read+customers:read
&state=RANDOM_STATE
&code_challenge=BASE64URL_SHA256_OF_VERIFIER
&code_challenge_method=S256resource may be omitted. MCP clients typically send
resource=https://oauth.servicebay.io/mcp. REST clients may send the
developer-site origin; it is rewritten to MCP. An invalid resource
redirects to the client-validated redirect_uri with
error=invalid_target. If the authorization request itself cannot be
parsed (unknown client, bad redirect), the server returns JSON 400
instead of redirecting.
The user signs in with their Servicebay account (TOTP required), chooses which organisation to connect, and reviews the requested permissions. Only active organisation admins can authorize. The user may grant fewer scopes than you requested — never more.
Exchange the code for tokens
curl -X POST 'https://oauth.servicebay.io/token' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'grant_type=authorization_code' \
-d 'code=AUTH_CODE_FROM_CALLBACK' \
-d 'redirect_uri=https://example.com/oauth/callback' \
-d 'client_id=YOUR_CLIENT_ID' \
-d 'client_secret=YOUR_CLIENT_SECRET' \
-d 'code_verifier=YOUR_PKCE_VERIFIER'The response contains an access_token (1 hour), a refresh_token
(30 days, rotates on every use), and the granted scope.
offline_access is accepted; refresh tokens are always issued.
Call the API with the access token
curl 'https://developer.servicebay.io/api/v1/organisations/{orgId}/tickets' \
-H 'Authorization: Bearer YOUR_ACCESS_TOKEN'The token is bound to the organisation the user selected; requests to
any other organisation fail with 403.
Refresh when the access token expires
curl -X POST 'https://oauth.servicebay.io/token' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'grant_type=refresh_token' \
-d 'refresh_token=YOUR_REFRESH_TOKEN' \
-d 'client_id=YOUR_CLIENT_ID' \
-d 'client_secret=YOUR_CLIENT_SECRET'Each refresh returns a new refresh token; discard the old one. If the
grant itself has expired, the token endpoint returns invalid_grant.
Scope model (v2)
New grants and API keys use scope version 2. Scopes are
{entity}:read, {entity}:write, and {entity}:delete, plus bundles
read / write / full and the no-op offline_access.
write does not include delete. A grant with tickets:write can
create and update jobs but cannot DELETE a job. Sub-resource deletes
that are really edits of the parent (ticket line items, discount, invoice
reminders, quote/invoice share links) require only the parent write
scope — see the route table below.
Grants and keys issued before this change keep v1 semantics via
scopeVersion (absent version is treated as 1). On v1, write still
implies delete.
| Scope | Meaning |
|---|---|
{entity}:read | GET / HEAD |
{entity}:write | POST / PUT / PATCH (implies read, not delete) |
{entity}:delete | DELETE of that entity (implies read, not write) |
read | Every entity's :read |
write | Every entity's :write (and :read) — not delete |
full | Every offered action, including delete |
offline_access | Accepted; refresh tokens are always issued |
Entities
| Entity | Path segments | Actions | Notes |
|---|---|---|---|
organisation | (org root) | read, write | No delete |
tickets | tickets | read, write, delete | Jobs. Deleting a job also deletes its calendar event |
customers | customers | read, write, delete | Deleting a customer also deletes their devices and, by default, their jobs and calendar events. A cascade that would delete jobs requires tickets:delete as well |
devices | devices | read, write, delete | Nested under a customer |
services | services | read, write, delete | |
inventory | inventory | read, write, delete | |
quotes | quotes | read, write, delete | |
invoices | invoices | read, write, delete | |
payments | payments | read, write, delete | Deleting a payment re-opens the invoice balance |
calendar | calendar-events | read, write, delete | |
stations | stations | read, write, delete | |
tax-rates | tax-rates | read, write, delete | |
ticket-statuses | ticket-statuses | read, write, delete | Job statuses |
messages | messages | read, write, delete | Nested under a job |
recurring-schedules | recurring-schedules, recurring-presets | read, write, delete | |
categories | inventory-categories, asset-categories | read, write, delete | |
forms | form-templates, form-rules, form-submissions | read only | |
members | members | read only |
Route → scope (deny by default)
The API maps the path after /api/v1/organisations/{orgId} plus the HTTP
method to the scopes that must all be held. An unmapped segment
returns 403 { "code": "unknown_resource" }.
Default: first recognised resource segment wins.
| Method | Required scope |
|---|---|
| GET, HEAD | {entity}:read |
| POST, PUT, PATCH | {entity}:write |
| DELETE | {entity}:delete |
Exceptions (matched before the default):
| Method | Path (after /organisations/{orgId}) | Required scopes |
|---|---|---|
| POST | /quotes/{id}/convert | quotes:write and invoices:write |
| POST | /invoices/{id}/send | invoices:write |
| POST | /quotes/{id}/send | quotes:write |
| PUT | /tickets/{id}/status | tickets:write |
| GET | /customers/lookup | customers:read |
| GET | /tickets/{id}/services, /tickets/{id}/inventory | tickets:read |
| POST, PUT, DELETE | /tickets/{id}/services, /tickets/{id}/inventory | tickets:write |
| GET | /tickets/{id}/discount, /totals, /signature | tickets:read |
| PUT | /tickets/{id}/discount, /signature | tickets:write |
| DELETE | /tickets/{id}/discount | tickets:write |
| POST, DELETE | /invoices/{id}/share-link | invoices:write |
| POST, DELETE | /quotes/{id}/share-link | quotes:write |
| GET | /invoices/{id}/reminders | invoices:read |
| POST, PUT, DELETE | /invoices/{id}/reminders | invoices:write |
| POST | /recurring-schedules/{id}/pause, /resume, /end, /resync, /generate | recurring-schedules:write |
| POST | /recurring-schedules/{id}/occurrences/{index}/skip, /move, /resize | recurring-schedules:write |
| GET | /form-submissions/{id}/signatures, /events | forms:read |
Missing permission → 403 with both code and requiredScopes (always):
{
"success": false,
"error": "Insufficient scope. This request requires 'tickets:write'.",
"code": "insufficient_scope",
"requiredScopes": ["tickets:write"]
}Header (scope is the space-delimited requiredScopes list):
WWW-Authenticate: Bearer error="insufficient_scope", scope="tickets:write", resource_metadata="https://developer.servicebay.io/.well-known/oauth-protected-resource"DELETE /customers/{id} with jobs and default cascade=true requires
both customers:delete and tickets:delete
(requiredScopes: ["customers:delete","tickets:delete"]).
MCP tool errors carry the same challenge in
_meta["mcp/www_authenticate"] so the client can step up.
Registering a client
Three ways to get a client_id:
Dynamic client registration (RFC 7591)
curl -X POST 'https://oauth.servicebay.io/register' \
-H 'Content-Type: application/json' \
-d '{
"client_name": "My Integration",
"client_uri": "https://example.com",
"redirect_uris": ["https://example.com/oauth/callback"],
"token_endpoint_auth_method": "client_secret_basic"
}'Hardened DCR rules:
client_nameis required- At least one
redirect_uri, at most 10 - Each redirect must be
https, loopbackhttp/https, or a non-reserved custom scheme (e.g. Cursor'scursor://anysphere.cursor-mcp/oauth/callback). Reserved schemes (javascript:,data:,file:, …) and non-loopbackhttpare rejected.application_typeis not required for custom schemes client_uriandlogo_uri, if set, must behttps- All DCR clients expire after 365 days (public and confidential)
GET/PUT/DELETE /register/{id}→ 501 (RFC 7592 is not implemented — manage pre-registered clients in the portal)- Public DCR (
token_endpoint_auth_method: none) is blocked for redirect hosts underperplexity.ai— create a pre-registered confidential client instead
Rate limits (per IP): 10/min and 100/day. Over limit → 429
temporarily_unavailable with Retry-After.
Client ID Metadata Documents (CIMD)
Claude, ChatGPT, Zed, and Claude Code can present a hosted client
metadata URL instead of calling /register. CIMD is enabled on the
authorization server. Optional host blocklist: CIMD_BLOCKED_HOSTS.
Pre-registered clients (portal)
Pro admins can create up to 10 confidential clients per organisation on OAuth Clients. These never expire. The secret is shown once; rotate it from the same page. Prefer this when the host needs a fixed ID (Perplexity, Copilot Studio, Cursor/VS Code static OAuth).
Consent screen
The consent page is a per-resource matrix. The admin:
- Picks one organisation
- Grants a subset of the requested scopes (never more)
- Sets grant expiry: no expiry, 24 hours, 7 days, 30 days, or 90 days
- May tick Don't ask again for pre-registered and CIMD clients only (remembered for 90 days). DCR clients cannot be remembered.
Approving again replaces the previous grant for that client + user.
Connected Apps can narrow a grant later (never widen). Widening requires the app to reconnect. Every admin of the org sees every grant, with a Connected-by column.
Remembered approvals can be forgotten from the same page without revoking the current grant.
Revocation
RFC 7009 endpoint. Authorization-server metadata always advertises
revocation_endpoint as /revoke (not the library default /token):
POST https://oauth.servicebay.io/revoke
Content-Type: application/x-www-form-urlencoded
token=ACCESS_OR_REFRESH_TOKEN
&token_type_hint=access_tokenAlways 200 when the request is well-formed, even if the token is
unknown or already revoked. Confidential clients should authenticate.
The legacy form — POST /token with token and no grant_type —
still works.
On success the Worker writes a tombstone so introspection fails within seconds (the 60 s introspection cache is bypassed). You can also revoke from Connected Apps.
If the authorization server is down, returns 5xx/429, or the shared
internal secret does not match, REST introspection returns 503
Unable to validate access token. Please try again. and does not
charge the failed-auth bucket. That is not a 401.
Grants are bound to the approving admin. If that person is removed,
deactivated, or demoted, subsequent API calls return 403 and remaining
grants for that user + organisation are revoked automatically.
Rate limits
OAuth-authenticated REST calls share the same shape as API keys:
100 requests per minute per clientId + organisation, reported
through X-RateLimit-* and Retry-After on 429. See
Rate Limiting.
The authorization server itself:
| Path | Limit (per IP) |
|---|---|
/authorize | 30 / minute |
/token and /revoke | 60 / minute |
/register | 10 / minute and 100 / day |
Over those limits → 429 with Retry-After and body
{ "error": "temporarily_unavailable" }.