OAuth 2.1
Authorize third-party apps with OAuth 2.1, PKCE, and resource indicators.
OAuth lets your app act on behalf of a Zoop user — reading their customers, creating jobs, or sending invoices — without ever seeing their password. The user approves access through a consent screen; you get a short-lived access token (a credential your app includes on every API request) and a refresh token (used to get a new access token when the first one expires).
Use OAuth when your app serves multiple Zoop accounts or when you need access tied to a specific user's identity. If you are building a server-to-server integration that runs unattended and does not need a user identity, API keys are simpler.
How it works
Zoop uses OAuth 2.1 with PKCE (Proof Key for Code Exchange — a security extension that stops attackers from hijacking the authorization code in transit). The auth server is Supabase — Zoop owns the consent screen and the grant table, but the actual token exchange happens against the Supabase auth endpoint.
The access token is a JWT (JSON Web Token) — a signed string that encodes the user's identity, tenant, and allowed scopes. Zoop verifies it on every request without a database lookup. The signature algorithm is ES256.
Your app Supabase auth Zoop
| | |
|-- GET /authorize --> | |
| |-- 302 → consent --> |
| | |
| (user approves) | |
| | |
|-- POST /token ------> | |
| | |
|<-- access_token + refresh_token -------------|
| |
|-- POST /api/mcp (Bearer <access_token>) ---> |
Prerequisites
Before you run this flow, you need an OAuth client and a redirect URI.
-
An OAuth client. Public clients — ChatGPT, Claude, MCP connectors, native apps, SPAs — self-register against the Supabase auth server using RFC 7591 Dynamic Client Registration. No Zoop admin is involved. You discover the
registration_endpointfrom the authorization server metadata (see Discovery) and POST your client details to it; you get back aclient_id. See Self-serve client registration below. Confidential (server-side) clients that need aclient_secretcan still be registered by a Zoop admin. -
A redirect URI. The URL in your app where Zoop sends the user after they approve access. You register it with the client — unregistered URIs are rejected.
A freshly registered client holds no access until a user completes the consent flow (default-deny). What that user can grant is bounded by their ceiling, not by the client alone — see Scopes below.
If you are building an MCP integration, the OAuth flow is the same. The token you receive can be sent directly to POST /api/mcp with an Authorization: Bearer header.
Discovery
Before you build any URLs, find out which authorization server your Zoop instance uses. Do not hardcode it — resolve it from the protected-resource metadata endpoint:
GET https://app.zoop.pro/.well-known/oauth-protected-resource
Response:
{
"resource": "https://app.zoop.pro",
"authorization_servers": ["https://<project>.supabase.co/auth/v1"],
"bearer_methods_supported": ["header"],
"resource_documentation": "https://docs.zoop.pro/api"
}
The resource value is environment-specific (production, staging, local). Always read it from the discovery response — do not hardcode https://app.zoop.pro. You will need the exact resource string when you build the authorization URL and the token request.
The authorization_servers array tells you which Supabase project handles auth for this instance. Use that URL to fetch the authorization server metadata — this gives you the exact authorization_endpoint and token_endpoint you will need in the steps below:
GET https://<project>.supabase.co/auth/v1/.well-known/oauth-authorization-server
This endpoint returns standard OAuth 2.0 server metadata: supported grants, PKCE methods, scopes, and the registration_endpoint you use for self-serve client registration.
Self-serve client registration
Public MCP clients register themselves — there is no Zoop admin step. Fetch the authorization server metadata (see Discovery), read its registration_endpoint, and POST your client metadata there following RFC 7591 Dynamic Client Registration. You get back a client_id (and, for confidential clients, a client_secret). Registration runs against the Supabase auth server, not the Zoop app.
A self-registered client starts with no allow-list and no access. At consent it is offered the connecting member's whole ceiling to choose from — there is no fixed read-only default scope set. See Scopes.
A self-registered client that never completes consent is removed automatically after 30 days. To keep a client alive, have a user complete the consent flow for it.
The authorization flow
Generate PKCE parameters and state
You need two random values before you redirect the user.
code_verifier— a random secret only your server knows. You hash it into acode_challengeand send the hash to the authorization endpoint. Later, you prove you hold the original secret by sending the verifier to the token endpoint. This stops an attacker who intercepts the authorization code from using it.state— a random value you tie to the user's session. You check it matches in the callback to block cross-site request forgery (CSRF) attacks.
import { randomBytes, createHash } from 'crypto'
const codeVerifier = randomBytes(64).toString('base64url')
const codeChallenge = createHash('sha256').update(codeVerifier).digest('base64url')
const state = randomBytes(32).toString('base64url')
// Store both in the user's session — you need them in the callback.
session.codeVerifier = codeVerifier
session.oauthState = state
Store codeVerifier and state server-side (signed cookie, Redis session, or similar) — you need both in steps 3 and 4.
Redirect the user to the authorization endpoint
Build the authorization URL using the authorization_endpoint from discovery, then redirect the user's browser to it.
GET https://<project>.supabase.co/auth/v1/oauth/authorize
?client_id=<your_client_id>
&redirect_uri=<your_registered_redirect_uri>
&response_type=code
&code_challenge=<codeChallenge>
&code_challenge_method=S256
&state=<state>
&scope=openid
&resource=https://app.zoop.pro
Key parameters:
Your registered client ID.
Must exactly match a URI pre-registered with the client.
Always code.
The base64url-encoded SHA-256 hash of your code_verifier.
Always S256. Plain is not supported.
Your CSRF token. Verified in the callback.
OIDC scopes only (openid, email). Zoop scopes are not set here — they are offered at consent from the connecting member's ceiling ∩ the client's allow-list, so what you send here does not change what appears on the consent screen. See Scopes.
The resource value from /.well-known/oauth-protected-resource (RFC 8707). Required by MCP-spec-compliant clients. Must match exactly.
Supabase validates the parameters and redirects the user to the Zoop consent screen:
https://app.zoop.pro/oauth/consent?authorization_id=<opaque_id>
If the user is not signed in, they see the login page first and are returned here after. If they have multiple Zoop tenants, they choose which one to give your app access to.
The consent screen groups the access being requested into plain-language permission areas (Customers & contacts, Jobs & scheduling, and so on), each shown as None, View, or Edit. The areas offered come from the member's ceiling: a fresh self-registered client is offered the member's whole ceiling to pick from, and a client that pre-registered scopes is narrowed down to that ceiling. The OIDC scope parameter you sent above does not drive this. See Scopes and the permission areas reference.
One grant per client and user. If a user re-consents for a different tenant, the previous grant is overwritten — your app loses access to the old tenant and gains access to the new one. You cannot hold grants for multiple tenants from the same user simultaneously.
Handle the callback
After the user approves on the consent screen, Supabase redirects them back to your redirect_uri with two query parameters:
https://your-app.example.com/callback?code=<authorization_code>&state=<your_state>
Verify state before doing anything else. Compare it to the value you stored in step 1. If they do not match, reject the request — this is a sign of a CSRF or authorization code injection attempt.
const { code, state } = new URL(request.url).searchParams
if (state !== session.oauthState) {
throw new Error('State mismatch — possible CSRF attack')
}
// Safe to proceed.
If the user has already approved this client before, Zoop skips the consent screen and redirects immediately.
Exchange the code for tokens
Send the authorization code and your code_verifier to the token endpoint. The server checks that the verifier hashes to the code_challenge you sent in step 2 — this proves your app made both requests.
Confidential clients have a client_secret. Send it as HTTP Basic auth.
curl -X POST https://<project>.supabase.co/auth/v1/oauth/token \
-H "Authorization: Basic $(echo -n '<client_id>:<client_secret>' | base64)" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=authorization_code" \
--data-urlencode "code=<authorization_code>" \
--data-urlencode "redirect_uri=<your_registered_redirect_uri>" \
--data-urlencode "code_verifier=<codeVerifier>" \
--data-urlencode "resource=https://app.zoop.pro"
Public clients have no client_secret. Send client_id in the body instead.
curl -X POST https://<project>.supabase.co/auth/v1/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=authorization_code" \
--data-urlencode "client_id=<your_client_id>" \
--data-urlencode "code=<authorization_code>" \
--data-urlencode "redirect_uri=<your_registered_redirect_uri>" \
--data-urlencode "code_verifier=<codeVerifier>" \
--data-urlencode "resource=https://app.zoop.pro"
The resource parameter must appear in both the authorization request (step 2) and this token request.
Successful response:
{
"access_token": "<es256-signed-jwt>",
"token_type": "bearer",
"expires_in": 3600,
"refresh_token": "<opaque-refresh-token>"
}
The access token expires in 3600 seconds (1 hour). Store the refresh token somewhere safe — you use it to get a new access token without sending the user through the browser flow again. See Refreshing tokens below.
Call the API
Send the access token in the Authorization header on every API request, using the Bearer scheme.
curl -X POST https://app.zoop.pro/api/mcp \
-H "Authorization: Bearer <access_token>" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
The Accept: application/json, text/event-stream header is required — Zoop's MCP endpoint uses the streamable HTTP transport and needs both content types declared.
const res = await fetch('https://app.zoop.pro/api/mcp', {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'Accept': 'application/json, text/event-stream',
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: {
name: 'customers_search',
arguments: { q: 'Henderson' },
},
}),
})
The access token
The access token is a JWT signed with ES256. If you want to inspect what's inside — for example, to check which tenant or scopes were granted — paste it into jwt.io. Zoop validates these claims on every request.
{
"iss": "https://<project>.supabase.co/auth/v1",
"sub": "<user_uuid>",
"tenant_id": "<tenant_uuid>",
"scopes": ["read:customers", "read:jobs"],
"client_id": "<your_client_id>",
"role": "owner"
}
Issuer. Pinned to the Supabase project's auth endpoint. Tokens from any other issuer are rejected.
The Supabase user UUID of the user who granted consent.
The UUID of the Zoop tenant the user bound the grant to. All API calls with this token are scoped to this tenant.
The scopes granted at consent time — not whatever was in the scope parameter at authorization. Effective access is re-checked against the member's current ceiling on every request, so it can be narrower than what's printed here (for example, if an owner lowered that member's ceiling after the token was issued).
Your OAuth client ID.
The user's role in the tenant: owner, office, or tech. Zoop coerces unrecognized values to tech (least-privileged).
Refreshing tokens
Access tokens expire after one hour. When that happens, use the refresh token to get a new one — no browser redirect needed.
curl -X POST https://<project>.supabase.co/auth/v1/oauth/token \
-H "Authorization: Basic $(echo -n '<client_id>:<client_secret>' | base64)" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=refresh_token" \
--data-urlencode "refresh_token=<refresh_token>" \
--data-urlencode "resource=https://app.zoop.pro"
curl -X POST https://<project>.supabase.co/auth/v1/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=refresh_token" \
--data-urlencode "client_id=<your_client_id>" \
--data-urlencode "refresh_token=<refresh_token>" \
--data-urlencode "resource=https://app.zoop.pro"
A successful response has the same shape as the original token response — a new access_token and expires_in, and sometimes a new refresh_token. If you get a new refresh token, store it and discard the old one.
If the refresh request returns 401 with error: "invalid_token", the user's session has been terminated (for example, they signed out). You need to send them through the full authorization flow again.
Scopes
What your app can read or write is decided at consent, not by the scope parameter in the authorization request (that parameter only carries OIDC scopes like openid). The scopes a user can grant are bounded by the connecting member's ceiling ∩ your client's allow-list:
- A fresh self-registered client has no allow-list, so it is offered the member's whole ceiling to choose from.
- A client that pre-registered scopes is narrowed down to the member's ceiling — anything outside the ceiling is dropped.
- If the member's ceiling is empty, nothing is offered and the grant gives no access.
There is no fixed read-only default scope set for self-registered clients anymore. Granted scopes are re-checked against the member's current ceiling on every request, so if an owner lowers that member's ceiling later, the token narrows immediately. See scopes & permissions for the ceiling model and the permission areas shown on the consent screen.
Common scope sets to request:
| Use case | Scopes |
|---|---|
| Read-only field service data | read:customers read:jobs read:invoices read:quotes |
| Full field service read/write | read:customers write:customers read:jobs write:jobs read:invoices write:invoices |
| Catalog management | read:catalog write:catalog |
Request only the scopes your integration actually needs. See scopes for the full catalog.
Error reference
These are the errors you are most likely to see from the API after you have a token.
| Situation | Status | Body |
|---|---|---|
| Token expired | 401 | {"error":"expired"} — use the refresh token |
| OAuth token invalidated (session terminated) | 401 | {"error":"invalid_token"} — restart the authorization flow |
| Insufficient scope | 403 | {"error":"insufficient_scope"} — the token does not hold the required scope |
| Wrong tenant | 403 | {"error":"wrong_tenant"} — the token's tenant_id does not match the requested tenant |
| Rate limited | 429 | Retry-After header included |
| JWKS endpoint down | 5xx | Retryable — Zoop rethrows JWKS infrastructure failures so you get a retryable 5xx, not a misleading 401 |
Retry 5xx responses with exponential backoff. Do not discard your token on a 5xx — it may be valid and the error transient.
Related
- API keys — simpler M2M authentication without a browser flow
- Scopes — full scope catalog
- MCP — calling the MCP endpoint with your access token
- Errors — full error code reference
- Rate limits — per-credential and per-tenant limits