Integrations, OAuth and scopes
How an app gets an OAuth access token for your organisation, which scopes exist, and how apps can register themselves.
Apps and AI agents that connect to Supporter Club use OAuth 2 to get an access token for one organisation. An app gets a token in one of two ways: a user signed in to the organisation approves the app's request on a consent screen (Authorize or Deny, for the scopes the app asked for), or the app requests a token with its own client credentials, with no consent screen. Administrators create integrations and revoke their tokens on the Integrations screen. Revoking ends the tokens the app holds; the app's client credentials keep working. The rest of this page is a reference for the developer building the app.
Where the endpoints live
Every endpoint on this page is served on your organisation's own Supporter Club address: the same host your admin and log-in page use. Supporter Club works out the organisation from the first part of the host name (the organisation's subdomain). Nothing in a request body or parameter can choose a different organisation.
The examples use https://your-organisation.example in place of that host.
| Method | Path | Purpose | Authentication |
|---|---|---|---|
GET | /.well-known/oauth-authorization-server | Authorization server metadata | None |
GET | /.well-known/oauth-protected-resource | Protected resource metadata for the MCP server | None |
POST | /oauth/register | Dynamic client registration | None (rate limited) |
GET | /oauth/authorize | Start the authorization code flow in the user's browser | The user's signed-in session |
POST | /oauth/authorize | Sent by the consent screen's Authorize button | The user's signed-in session |
DELETE | /oauth/authorize | Sent by the consent screen's Deny button | The user's signed-in session |
POST | /oauth/token | Exchange a code, refresh a token, or use client credentials | Client authentication |
POST | /oauth/revoke | Revoke an access or refresh token | Client authentication |
POST | /oauth/introspect | Check whether a token is active | Client authentication or a bearer token |
GET | /oauth/token/info | Describe the bearer token sent with the request | Bearer token |
A client_id that belongs to another organisation, or that doesn't exist, gets 404 Not Found with an empty body from /oauth/token, /oauth/revoke and /oauth/introspect. At /oauth/authorize the sign-in check comes first: a browser that isn't signed in is sent to the log-in page, and only a signed-in user gets the 404.
Getting client credentials
You can get a client_id in two ways:
- An administrator creates an integration on the Integrations screen. They choose whether it is a confidential client and which of the five advertised scopes to register for it. If they tick none, the client has no scopes registered (see Scopes for what that allows). The Integration created screen shows the
client_idand aclient_secretonce, for public and confidential clients alike. - The app registers itself with dynamic client registration. A
client_secretis returned only for a confidential registration. Apps registered this way also appear on the Integrations screen.
Confidential and public clients differ like this:
| Confidential client | Public client | |
|---|---|---|
Has a client_secret | Yes | Created on the Integrations screen: yes, but it doesn't have to be sent. Self-registered: none is returned. |
| Authenticates at the token endpoint with | client_id and client_secret, in an HTTP Basic Authorization header or in the request body | client_id in the request body, with no secret |
| PKCE on the authorization code flow | Optional (if sent, it must use S256) | Required, S256 only |
| Grant types it can use | authorization_code, refresh_token, client_credentials | authorization_code, refresh_token, client_credentials |
Grant types
Three grant types are enabled: authorization_code, refresh_token and client_credentials. They are enabled for every client. There is no per-client setting, and public and confidential clients can use all three. The grant_types field in a registration response lists only authorization_code and refresh_token, but that field doesn't restrict the client: a self-registered client can also use client credentials.
Scopes
Supporter Club offers five scopes. They are advertised in the discovery documents, are the only ones that can be registered for a client, and are the ones the MCP tools check. All five are read-only.
| Scope | Shown on the consent screen as | What it allows |
|---|---|---|
read:documentation | View API documentation | The MCP documentation tools. This is the default scope. |
read:organisation | View organisation details | The MCP get_organisation tool |
read:clubs | View clubs | The MCP list_clubs and get_club tools |
read:memberships | View club memberships | The MCP list_memberships and get_membership tools |
read:donations | View donations | The MCP list_donations and get_donation tools |
Send scopes as one space-separated string, for example scope=read:clubs read:donations.
- If the client has scopes registered, it can request only those. Asking for any other scope fails with
invalid_scope. - If the client has no scopes registered, it can request any of the scopes above. It can also request other scopes the server recognises, but nothing on this page or on the MCP server checks them, so they give a token no extra access. A client has no scopes registered when it registered itself without
scope, or when an administrator created it on the Integrations screen without ticking any permission. - If you leave
scopeout, the request asks for the default scope,read:documentation. This works when the client has no scopes registered or hasread:documentationamong them. If the client has scopes registered withoutread:documentation, leavingscopeout fails withinvalid_scope, so name the scopes you need. - A token holds only the scopes in its request.
read:documentationis not added to a request that names other scopes. - These rules are the same for the authorization code flow and for client credentials.
See Connecting an AI agent (MCP) for what each tool returns.
Discovery documents
Both documents are public and sent with Cache-Control: max-age=3600, public. The URLs inside them use the scheme and host of the request.
Authorization server metadata
GET /.well-known/oauth-authorization-server HTTP/1.1
Host: your-organisation.example{
"issuer": "https://your-organisation.example",
"authorization_endpoint": "https://your-organisation.example/oauth/authorize",
"token_endpoint": "https://your-organisation.example/oauth/token",
"registration_endpoint": "https://your-organisation.example/oauth/register",
"scopes_supported": [
"read:documentation",
"read:organisation",
"read:clubs",
"read:memberships",
"read:donations"
],
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "client_credentials", "refresh_token"],
"token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post", "none"],
"code_challenge_methods_supported": ["S256"]
}Protected resource metadata
GET /.well-known/oauth-protected-resource HTTP/1.1
Host: your-organisation.example{
"resource": "https://your-organisation.example/mcp",
"authorization_servers": ["https://your-organisation.example"],
"scopes_supported": [
"read:documentation",
"read:organisation",
"read:clubs",
"read:memberships",
"read:donations"
],
"bearer_methods_supported": ["header"]
}Dynamic client registration
POST /oauth/register lets an app register itself, for example an MCP client connecting for the first time. It needs no credentials and no signed-in user. The client_id it returns can be used straight away with any of the three grant types, including client credentials, which issues a token with no consent screen.
Request body
Send a JSON object (Content-Type: application/json).
| Field | Required | Description |
|---|---|---|
redirect_uris | Yes | Array of redirect URIs. See Redirect URIs for the rules. |
client_name | No | The name shown on the consent screen and the Integrations screen. Defaults to MCP Client. |
scope | No | Space-separated string of scopes the app may request. Scopes other than the five advertised scopes are dropped. If you leave it out, the response's scope is empty and the client has no scopes registered, so it isn't limited to particular scopes (see Scopes). |
token_endpoint_auth_method | No | none (the default) registers a public client. client_secret_basic or client_secret_post registers a confidential client and returns a client_secret. |
The body must not contain organisation, organisation_id, organization or organization_id. It must not offer the plain PKCE method in code_challenge_method or code_challenge_methods_supported.
Example: public client
POST /oauth/register HTTP/1.1
Host: your-organisation.example
Content-Type: application/json
{
"client_name": "Fundraising assistant",
"redirect_uris": ["http://127.0.0.1:33418/callback"],
"scope": "read:clubs read:donations offline_access",
"token_endpoint_auth_method": "none"
}HTTP/1.1 200 OK
Content-Type: application/json
X-RateLimit-Limit: 10
X-RateLimit-Remaining: 9
X-RateLimit-Reset: 1791367200
{
"client_id": "8kQf2mV0b1Xc7RzN4tLwYp3sHd6uJe9aGo5iKqTn1Bc",
"redirect_uris": ["http://127.0.0.1:33418/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"token_endpoint_auth_method": "none",
"scope": "read:clubs read:donations",
"client_id_issued_at": 1791363600
}offline_access was dropped from scope because it is not one of the five advertised scopes. A confidential registration returns the same fields plus client_secret. This is the only time the secret is returned, so store it.
Errors
Invalid metadata returns 400 Bad Request:
{
"error": "invalid_client_metadata",
"error_description": "redirect_uris is required"
}error_description | Cause |
|---|---|
redirect_uris is required | redirect_uris is missing or empty. |
Unsupported token_endpoint_auth_method | The method isn't none, client_secret_basic or client_secret_post. |
Requested scope is not advertised by this server | scope was sent but none of its scopes can be registered. |
Organisation is derived from the request host and cannot be supplied in the payload | The body names an organisation. |
The 'plain' PKCE code challenge method is not permitted | The body offers plain PKCE. |
| A description of the redirect URI problem | A redirect URI breaks the redirect URI rules. |
Rate limits
Registration is limited in fixed one-hour windows that start on the hour:
- 10 requests per hour from one IP address;
- 50 requests per hour for one organisation.
Every request counts, including failed ones. Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (Unix time, in seconds, when the window ends). They describe whichever of the two limits has fewer requests left. Over either limit, the endpoint returns 429 Too Many Requests:
{ "error": "slow_down" }Redirect URIs
- A redirect URI must be absolute (it must have a scheme) and must not contain a fragment (
#...). httpsURIs are accepted.- Plain
httpis accepted only for a loopback address: a loopback IP address, such ashttp://127.0.0.1:33418/callbackorhttp://[::1]:33418/callback, or the host namelocalhost, such ashttp://localhost:33418/callback. Any otherhttpURI is refused. - Other schemes, such as an app's own custom scheme (
com.example.app://callback), are accepted. - At
/oauth/authorizeand/oauth/token, theredirect_urimust match one of the client's registered URIs exactly. For loopback URIs, includinglocalhost, the port is ignored, so a native app can listen on any port.
Authorization code flow
Use this flow when a user signed in to the organisation approves your app. Public clients must use PKCE with S256.
1. Send the user to the authorization endpoint
Open this URL in the user's browser:
https://your-organisation.example/oauth/authorize
?response_type=code
&client_id=8kQf2mV0b1Xc7RzN4tLwYp3sHd6uJe9aGo5iKqTn1Bc
&redirect_uri=http%3A%2F%2F127.0.0.1%3A33418%2Fcallback
&scope=read%3Aclubs%20read%3Adonations
&state=af0ifjsldkj
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256| Parameter | Required | Notes |
|---|---|---|
response_type | Yes | code |
client_id | Yes | |
redirect_uri | Yes | Must match a registered URI. |
scope | No | See Scopes. |
state | Recommended | Returned unchanged on the redirect. |
code_challenge | Yes for public clients | Base64url SHA-256 of your code_verifier. |
code_challenge_method | With code_challenge | S256. plain is refused. |
Other parameters, such as resource, are ignored.
2. The user signs in and approves
If the user isn't signed in to your organisation, they are sent to the log-in page first. Supporter Club signs people in with a link sent by email, and that link opens Supporter Club rather than your app's request. After signing in, the user has to start the connection again from your app, in the same browser.
A signed-in user sees the Authorize <client name> screen: "<client name> will be able to access <organisation name> on your behalf, with the following permissions:", followed by the descriptions of the requested scopes as listed in the Scopes tables, with Deny and Authorize buttons. The user can't change the scopes: Authorize approves all the requested scopes, and Deny refuses the request. Any user signed in on the organisation's address can approve. They don't need to be an administrator: donors and supporters who sign in, for example to their membership page, see the same screen and can approve.
For a confidential client, the screen is skipped if the same person has already approved the same client for the same scopes and that token has not been revoked. The code is then issued straight away.
3. Handle the redirect
If the user selects Authorize, the browser is redirected to your redirect_uri with a code that is valid for 10 minutes:
http://127.0.0.1:33418/callback?code=Splxl0BeZQQYbYS6WxSbIA0sLv3kTq9eRf2hGc8nWm4&state=af0ifjsldkjIf the user selects Deny:
http://127.0.0.1:33418/callback?error=access_denied&error_description=The+resource+owner+or+authorization+server+denied+the+request.&state=af0ifjsldkjIf the request itself is wrong, the user sees an "An error has occurred" page with the reason instead of being redirected. For example:
| Problem | Reason shown |
|---|---|
Public client sent no code_challenge | Code challenge is required. |
code_challenge_method is plain or missing | The code_challenge_method must be S256. |
redirect_uri doesn't match | The requested redirect uri is malformed or doesn't match client redirect URI. |
| Scope not allowed | The requested scope is invalid, unknown, or malformed. |
4. Exchange the code for a token
POST /oauth/token HTTP/1.1
Host: your-organisation.example
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=Splxl0BeZQQYbYS6WxSbIA0sLv3kTq9eRf2hGc8nWm4
&redirect_uri=http%3A%2F%2F127.0.0.1%3A33418%2Fcallback
&client_id=8kQf2mV0b1Xc7RzN4tLwYp3sHd6uJe9aGo5iKqTn1Bc
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXkA confidential client also authenticates, with Authorization: Basic base64(client_id:client_secret) or a client_secret field. A public client that used PKCE must send code_verifier.
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Cache-Control: no-store, no-cache
Pragma: no-cache
{
"access_token": "Yt7dP0sQe3Lm9VxR2cWbN8fHk5uJa1gZo6iTq4nEw0A",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "Hc2Lq8Vn5Rt1Xw9Pb4Ms7Kd0Fy3Gj6Ue2Za8Io5Qe1W",
"scope": "read:clubs read:donations",
"created_at": 1791363600
}Refreshing a token
POST /oauth/token HTTP/1.1
Host: your-organisation.example
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token
&refresh_token=Hc2Lq8Vn5Rt1Xw9Pb4Ms7Kd0Fy3Gj6Ue2Za8Io5Qe1W
&client_id=8kQf2mV0b1Xc7RzN4tLwYp3sHd6uJe9aGo5iKqTn1Bc- Send the same client authentication as for the code exchange. Without it, the request fails with
invalid_grant. - The response has the same shape as above, with a new access token and a new refresh token. The new access token lasts as long as the one it replaces: 1 hour (
"expires_in": 3600) for a token from the authorization code flow. The refresh token you sent stops working straight away. Sending it again returnsinvalid_grant. - You can send a narrower
scopethan the original. You can't widen it. - Refresh tokens have no expiry. They stop working when they are used, when the token is revoked, or when an administrator revokes the integration's tokens.
Client credentials
Any client, confidential or public, can get a token for itself with the client_credentials grant. No user is involved and no consent screen is shown. A confidential client authenticates with client_id and client_secret. A public client sends client_id in the request body, with no secret. The token can hold the scopes described in Scopes.
POST /oauth/token HTTP/1.1
Host: your-organisation.example
Authorization: Basic OGtRZjJtVjBiMVhjN1J6TjR0THdZcDNzSGQ2dUplOWFHbzVpS3FUbjFCYzpzM2NyM3Q=
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&scope=read:organisation read:clubs{
"access_token": "Pq4Wm1Zx8Tc3Vb6Nh0Ld9Rk2Ys5Fg7Ja1Ue4Io8Qe3K",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "read:organisation read:clubs",
"created_at": 1791363600
}This grant returns no refresh token. Request a new token when the old one expires. The token has no user (resource_owner_id is null), and the MCP server accepts it.
Token lifetimes
| Item | Lifetime |
|---|---|
| Authorization code | 10 minutes, single use |
Access token, from authorization_code or client_credentials | 1 hour (expires_in: 3600) |
Access token, from refresh_token | The same as the token it replaces: 1 hour (expires_in: 3600) |
| Refresh token | No expiry. Single use. |
An administrator can revoke every token for an integration at once on the Integrations screen. This revokes access and refresh tokens only. The integration's client_id and client_secret stay valid, so the client can get new tokens afterwards with any grant.
Token endpoint errors
Errors from /oauth/token have this shape:
{
"error": "invalid_grant",
"error_description": "The provided authorization grant is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client."
}error | Status | Typical cause |
|---|---|---|
invalid_request | 400 | A required parameter is missing, for example code_verifier from a public client ("Missing required parameter: code_verifier."). |
invalid_client | 401 | Wrong client_secret, or a confidential client sent no credentials. |
invalid_grant | 400 | The code or refresh token is wrong, expired, already used or revoked, or belongs to another client, or the code_verifier doesn't match. |
invalid_scope | 400 | A requested scope isn't allowed for this client. |
unsupported_grant_type | 400 | grant_type isn't authorization_code, refresh_token or client_credentials. |
Revoking a token
POST /oauth/revoke HTTP/1.1
Host: your-organisation.example
Content-Type: application/x-www-form-urlencoded
token=Yt7dP0sQe3Lm9VxR2cWbN8fHk5uJa1gZo6iTq4nEw0A&client_id=8kQf2mV0b1Xc7RzN4tLwYp3sHd6uJe9aGo5iKqTn1Bc- Authenticate as the client:
client_idfor a public client, orclient_idandclient_secret(Basic header or body) for a confidential one. tokencan be an access token or a refresh token. Addtoken_type_hint=refresh_tokenwhen sending a refresh token. An access token and its refresh token are revoked together.- The response is
200 OKwith{}, including for a token that doesn't exist. - A token issued to a different client returns
403 Forbidden:
{
"error": "unauthorized_client",
"error_description": "You are not authorized to revoke this token"
}Introspecting a token
POST /oauth/introspect HTTP/1.1
Host: your-organisation.example
Authorization: Basic OGtRZjJtVjBiMVhjN1J6TjR0THdZcDNzSGQ2dUplOWFHbzVpS3FUbjFCYzpzM2NyM3Q=
Content-Type: application/x-www-form-urlencoded
token=Yt7dP0sQe3Lm9VxR2cWbN8fHk5uJa1gZo6iTq4nEw0AAuthenticate with the client's credentials, or with Authorization: Bearer and a different, active token issued to the same client. An active token issued to that client returns:
{
"active": true,
"scope": "read:clubs read:donations",
"client_id": "8kQf2mV0b1Xc7RzN4tLwYp3sHd6uJe9aGo5iKqTn1Bc",
"token_type": "Bearer",
"iat": 1791363600,
"exp": 1791367200
}What comes back for a token that isn't active depends on how you authenticate:
- With client credentials: expired, revoked or unknown tokens, and tokens issued to another client, return
{"active": false}. - With a bearer token: an expired or revoked token issued to the same client returns
{"active": false}. An unknown token, a token issued to another client, or the bearer token itself returns401withinvalid_token. So does a bearer token that is itself expired or revoked.
However you authenticate, a request with no client credentials and no bearer token returns 400 with invalid_request, and wrong client credentials return 401 with invalid_client.
Token info
GET /oauth/token/info HTTP/1.1
Host: your-organisation.example
Authorization: Bearer Yt7dP0sQe3Lm9VxR2cWbN8fHk5uJa1gZo6iTq4nEw0A{
"resource_owner_id": 42,
"scope": ["read:clubs", "read:donations"],
"expires_in": 3412,
"application": { "uid": "8kQf2mV0b1Xc7RzN4tLwYp3sHd6uJe9aGo5iKqTn1Bc" },
"created_at": 1791363600
}expires_inis the number of seconds left.resource_owner_idisnullfor a client-credentials token.- An expired, revoked or unknown token returns
401 Unauthorizedwith"error": "invalid_token". - A valid token issued for a different organisation returns
404 Not Found.