Docs

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
EndpointPath
Authorization/authorize
Token/token
Dynamic client registration (RFC 7591)/register
IntrospectionInternal 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.

ValueResult
OmittedCanonicalised to https://oauth.servicebay.io/mcp
https://oauth.servicebay.io/mcpAccepted
https://developer.servicebay.ioAccepted as an alias, rewritten to the MCP resource
https://developer.servicebay.io/api/v1Accepted as an alias, rewritten to the MCP resource
Anything elseinvalid_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=S256

resource 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.

ScopeMeaning
{entity}:readGET / HEAD
{entity}:writePOST / PUT / PATCH (implies read, not delete)
{entity}:deleteDELETE of that entity (implies read, not write)
readEvery entity's :read
writeEvery entity's :write (and :read) — not delete
fullEvery offered action, including delete
offline_accessAccepted; refresh tokens are always issued

Entities

EntityPath segmentsActionsNotes
organisation(org root)read, writeNo delete
ticketsticketsread, write, deleteJobs. Deleting a job also deletes its calendar event
customerscustomersread, write, deleteDeleting 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
devicesdevicesread, write, deleteNested under a customer
servicesservicesread, write, delete
inventoryinventoryread, write, delete
quotesquotesread, write, delete
invoicesinvoicesread, write, delete
paymentspaymentsread, write, deleteDeleting a payment re-opens the invoice balance
calendarcalendar-eventsread, write, delete
stationsstationsread, write, delete
tax-ratestax-ratesread, write, delete
ticket-statusesticket-statusesread, write, deleteJob statuses
messagesmessagesread, write, deleteNested under a job
recurring-schedulesrecurring-schedules, recurring-presetsread, write, delete
categoriesinventory-categories, asset-categoriesread, write, delete
formsform-templates, form-rules, form-submissionsread only
membersmembersread 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.

MethodRequired scope
GET, HEAD{entity}:read
POST, PUT, PATCH{entity}:write
DELETE{entity}:delete

Exceptions (matched before the default):

MethodPath (after /organisations/{orgId})Required scopes
POST/quotes/{id}/convertquotes:write and invoices:write
POST/invoices/{id}/sendinvoices:write
POST/quotes/{id}/sendquotes:write
PUT/tickets/{id}/statustickets:write
GET/customers/lookupcustomers:read
GET/tickets/{id}/services, /tickets/{id}/inventorytickets:read
POST, PUT, DELETE/tickets/{id}/services, /tickets/{id}/inventorytickets:write
GET/tickets/{id}/discount, /totals, /signaturetickets:read
PUT/tickets/{id}/discount, /signaturetickets:write
DELETE/tickets/{id}/discounttickets:write
POST, DELETE/invoices/{id}/share-linkinvoices:write
POST, DELETE/quotes/{id}/share-linkquotes:write
GET/invoices/{id}/remindersinvoices:read
POST, PUT, DELETE/invoices/{id}/remindersinvoices:write
POST/recurring-schedules/{id}/pause, /resume, /end, /resync, /generaterecurring-schedules:write
POST/recurring-schedules/{id}/occurrences/{index}/skip, /move, /resizerecurring-schedules:write
GET/form-submissions/{id}/signatures, /eventsforms: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_name is required
  • At least one redirect_uri, at most 10
  • Each redirect must be https, loopback http/https, or a non-reserved custom scheme (e.g. Cursor's cursor://anysphere.cursor-mcp/oauth/callback). Reserved schemes (javascript:, data:, file:, …) and non-loopback http are rejected. application_type is not required for custom schemes
  • client_uri and logo_uri, if set, must be https
  • 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 under perplexity.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).

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_token

Always 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:

PathLimit (per IP)
/authorize30 / minute
/token and /revoke60 / minute
/register10 / minute and 100 / day

Over those limits → 429 with Retry-After and body { "error": "temporarily_unavailable" }.