Supporter Club
API & Integrations

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.

MethodPathPurposeAuthentication
GET/.well-known/oauth-authorization-serverAuthorization server metadataNone
GET/.well-known/oauth-protected-resourceProtected resource metadata for the MCP serverNone
POST/oauth/registerDynamic client registrationNone (rate limited)
GET/oauth/authorizeStart the authorization code flow in the user's browserThe user's signed-in session
POST/oauth/authorizeSent by the consent screen's Authorize buttonThe user's signed-in session
DELETE/oauth/authorizeSent by the consent screen's Deny buttonThe user's signed-in session
POST/oauth/tokenExchange a code, refresh a token, or use client credentialsClient authentication
POST/oauth/revokeRevoke an access or refresh tokenClient authentication
POST/oauth/introspectCheck whether a token is activeClient authentication or a bearer token
GET/oauth/token/infoDescribe the bearer token sent with the requestBearer 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_id and a client_secret once, for public and confidential clients alike.
  • The app registers itself with dynamic client registration. A client_secret is returned only for a confidential registration. Apps registered this way also appear on the Integrations screen.

Confidential and public clients differ like this:

Confidential clientPublic client
Has a client_secretYesCreated on the Integrations screen: yes, but it doesn't have to be sent. Self-registered: none is returned.
Authenticates at the token endpoint withclient_id and client_secret, in an HTTP Basic Authorization header or in the request bodyclient_id in the request body, with no secret
PKCE on the authorization code flowOptional (if sent, it must use S256)Required, S256 only
Grant types it can useauthorization_code, refresh_token, client_credentialsauthorization_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.

ScopeShown on the consent screen asWhat it allows
read:documentationView API documentationThe MCP documentation tools. This is the default scope.
read:organisationView organisation detailsThe MCP get_organisation tool
read:clubsView clubsThe MCP list_clubs and get_club tools
read:membershipsView club membershipsThe MCP list_memberships and get_membership tools
read:donationsView donationsThe 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 scope out, the request asks for the default scope, read:documentation. This works when the client has no scopes registered or has read:documentation among them. If the client has scopes registered without read:documentation, leaving scope out fails with invalid_scope, so name the scopes you need.
  • A token holds only the scopes in its request. read:documentation is 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).

FieldRequiredDescription
redirect_urisYesArray of redirect URIs. See Redirect URIs for the rules.
client_nameNoThe name shown on the consent screen and the Integrations screen. Defaults to MCP Client.
scopeNoSpace-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_methodNonone (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_descriptionCause
redirect_uris is requiredredirect_uris is missing or empty.
Unsupported token_endpoint_auth_methodThe method isn't none, client_secret_basic or client_secret_post.
Requested scope is not advertised by this serverscope was sent but none of its scopes can be registered.
Organisation is derived from the request host and cannot be supplied in the payloadThe body names an organisation.
The 'plain' PKCE code challenge method is not permittedThe body offers plain PKCE.
A description of the redirect URI problemA 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 (#...).
  • https URIs are accepted.
  • Plain http is accepted only for a loopback address: a loopback IP address, such as http://127.0.0.1:33418/callback or http://[::1]:33418/callback, or the host name localhost, such as http://localhost:33418/callback. Any other http URI is refused.
  • Other schemes, such as an app's own custom scheme (com.example.app://callback), are accepted.
  • At /oauth/authorize and /oauth/token, the redirect_uri must match one of the client's registered URIs exactly. For loopback URIs, including localhost, 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
ParameterRequiredNotes
response_typeYescode
client_idYes
redirect_uriYesMust match a registered URI.
scopeNoSee Scopes.
stateRecommendedReturned unchanged on the redirect.
code_challengeYes for public clientsBase64url SHA-256 of your code_verifier.
code_challenge_methodWith code_challengeS256. 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=af0ifjsldkj

If 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=af0ifjsldkj

If the request itself is wrong, the user sees an "An error has occurred" page with the reason instead of being redirected. For example:

ProblemReason shown
Public client sent no code_challengeCode challenge is required.
code_challenge_method is plain or missingThe code_challenge_method must be S256.
redirect_uri doesn't matchThe requested redirect uri is malformed or doesn't match client redirect URI.
Scope not allowedThe 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_wW1gFWFOEjXk

A 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 returns invalid_grant.
  • You can send a narrower scope than 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

ItemLifetime
Authorization code10 minutes, single use
Access token, from authorization_code or client_credentials1 hour (expires_in: 3600)
Access token, from refresh_tokenThe same as the token it replaces: 1 hour (expires_in: 3600)
Refresh tokenNo 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."
}
errorStatusTypical cause
invalid_request400A required parameter is missing, for example code_verifier from a public client ("Missing required parameter: code_verifier.").
invalid_client401Wrong client_secret, or a confidential client sent no credentials.
invalid_grant400The code or refresh token is wrong, expired, already used or revoked, or belongs to another client, or the code_verifier doesn't match.
invalid_scope400A requested scope isn't allowed for this client.
unsupported_grant_type400grant_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_id for a public client, or client_id and client_secret (Basic header or body) for a confidential one.
  • token can be an access token or a refresh token. Add token_type_hint=refresh_token when sending a refresh token. An access token and its refresh token are revoked together.
  • The response is 200 OK with {}, 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=Yt7dP0sQe3Lm9VxR2cWbN8fHk5uJa1gZo6iTq4nEw0A

Authenticate 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 returns 401 with invalid_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_in is the number of seconds left.
  • resource_owner_id is null for a client-credentials token.
  • An expired, revoked or unknown token returns 401 Unauthorized with "error": "invalid_token".
  • A valid token issued for a different organisation returns 404 Not Found.

On this page