Authentication
Club API keys and OAuth 2.1 sign-in: scopes, every endpoint of the sign-in flow, tokens and revoking.
Every call carries a bearer token in the Authorization header. There are two ways to get one, and both give the same kind of token, a club key that starts with fo_live_ (or fo_test_ for a test key), so the REST API and the MCP server treat them the same:
- An API key that a club admin creates and pastes into your code. Best for a club’s own scripts and integrations.
- OAuth 2.1 sign-in, where the admin signs in from your app and picks the club. Best for an app that many clubs connect. The token lasts an hour and refreshes.
Authorization: Bearer fo_live_…API keys
Open Connections
As a club admin, go to SettingsConnections and scroll to Keys, for apps that can’t sign in.Name it and pick what it may do
Name the key after where it will live, like “Results sync”. Pick Read-only for a read key, Allow changes for a write key, which acts as you, or Test key while you build.Copy it once
Click Create key. The key is shown once, with a ready-made Claude Code command and JSON config. Only a hash of it is stored, so it can’t be shown again.
| Key | What it can do |
|---|---|
| Read | Every lookup an admin can make: members, groups, classes, bookings, check-ins, payments, results, files and the activity log. It can’t change anything or contact anyone. |
| Write (Allow changes) | Lookups, and every change an admin can make through the API, as the admin who created the key. Each change asks first with a preview and a one-time confirm token. If that admin loses admin access, the key drops back to read-only on its next request. |
| Test (Test key) | Everything a write key can do, on the club’s real data, except that a confirmed change is only simulated: nothing is saved, charged or sent. See Test keys. |
- A key is
fo_live_orfo_test_followed by 43 characters. Anything else in the header is not read as a key. - A club can have 10 active keys. Revoke one you no longer use to make room.
- The list shows each key’s name, its first and last few characters, whether it is read-only, allows changes or is a test key, and when it was last used (updated at most every 10 minutes).
- Revoke stops a key on the next request. There’s no undo; create a new key instead.
Test keys
A test key lets you build and try an integration without changing anything. It starts with fo_test_ and works exactly like a write key until the last step:
- Lookups read the club’s real data, the same as any key.
- A change runs every check a live key gets: the key’s club, the admin who created it, the club’s rules in SettingsAssistant, input checks and the preview with its confirm token.
- The confirmed call returns
test: trueandchanged: falsewith the preview, instead of making the change. No data is saved, no money moves, nobody gets an email or text, and nothing goes in the activity log. - Errors are real, so a call that would fail with a live key fails the same way with a test key.
The confirmed call’s reply:
{
"test": true,
"changed": false,
"action": "group_add_members",
"preview": { "summary": "Add 1 member to Adults", "details": [] },
"message": "Test key: this passed every check, but nothing was changed, charged or sent. A live key would have made this change."
}When your integration works, create a live key and swap it in. Nothing else changes: the same calls, in the same order. A test key counts toward the club’s 10 keys, so revoke it when you’re done.
What a key can see
Members are read with a fixed set of fields: name, email, admin flag, groups, membership status and type, last check-in, USA Fencing id, waiver and family links (id, name and relationship). Phone numbers, birthdays, addresses and custom member fields are never read. Payment data comes only from the payments actions (invoices, subscriptions and amounts), never card numbers.
Sign in with OAuth
Fencing Office is an OAuth 2.1 authorization server, built to the MCP authorization spec (2025-06-18) so AI apps can connect with no setup. Any app can use it: register once, send the admin to the sign-in page, and swap the code for tokens. PKCE with S256 is required for every app.
1. Discovery
A call to /api/mcp without a token gets 401 with a header that points at the metadata, which is how MCP clients find the sign-in on their own:
WWW-Authenticate: Bearer realm="Fencing Office", resource_metadata="https://fencingoffice.com/.well-known/oauth-protected-resource/api/mcp"GET https://fencingoffice.com/.well-known/oauth-protected-resource/api/mcp
{
"resource": "https://fencingoffice.com/api/mcp",
"authorization_servers": ["https://fencingoffice.com"],
"scopes_supported": ["read", "write"],
"bearer_methods_supported": ["header"],
"resource_name": "Fencing Office",
"resource_documentation": "https://fencingoffice.com/dashboard/members?settings=connections"
}The same document is also at /.well-known/oauth-protected-resource. The authorization server’s own metadata (RFC 8414):
GET https://fencingoffice.com/.well-known/oauth-authorization-server
{
"issuer": "https://fencingoffice.com",
"authorization_endpoint": "https://fencingoffice.com/oauth/authorize",
"token_endpoint": "https://fencingoffice.com/api/oauth/token",
"registration_endpoint": "https://fencingoffice.com/api/oauth/register",
"revocation_endpoint": "https://fencingoffice.com/api/oauth/revoke",
"response_types_supported": ["code"],
"response_modes_supported": ["query"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"code_challenge_methods_supported": ["S256"],
"authorization_response_iss_parameter_supported": true,
"token_endpoint_auth_methods_supported": ["none", "client_secret_post", "client_secret_basic"],
"revocation_endpoint_auth_methods_supported": ["none"],
"scopes_supported": ["read", "write"],
"service_documentation": "https://fencingoffice.com/dashboard/members?settings=connections"
}2. Register your app
Dynamic client registration (RFC 7591). Registering grants nothing on its own; an admin still has to say yes. Public apps (token_endpoint_auth_method: none) are the default. An app that asks for client_secret_post or client_secret_basic also gets a client_secret, shown once, and must send it to the token endpoint, with PKCE as well.
curl https://fencingoffice.com/api/oauth/register \
-H "Content-Type: application/json" \
-d '{
"client_name": "Club Stats",
"redirect_uris": ["https://clubstats.example.com/oauth/callback"],
"token_endpoint_auth_method": "none",
"grant_types": ["authorization_code", "refresh_token"]
}'HTTP/1.1 201 Created
{
"client_id": "foc_…",
"client_id_issued_at": 1794400000,
"client_name": "Club Stats",
"redirect_uris": ["https://clubstats.example.com/oauth/callback"],
"token_endpoint_auth_method": "none",
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"]
}- One to ten
redirect_uris. Each must be https, http on127.0.0.1,localhostor[::1](any port, for desktop apps), or an app’s own scheme likecursor://. No fragments and no user names or passwords in them. grant_typesmay beauthorization_codeandrefresh_tokenonly.client_name(up to 60 characters) is what the admin sees on the sign-in page and in their list of signed-in apps.client_uriandlogo_uriare kept when they are https.
3. Send the admin to sign in
Open the authorization endpoint in a browser with a PKCE challenge:
https://fencingoffice.com/oauth/authorize
?response_type=code
&client_id=CLIENT_ID
&redirect_uri=https://clubstats.example.com/oauth/callback
&code_challenge=BASE64URL_SHA256_OF_VERIFIER
&code_challenge_method=S256
&scope=read%20write
&state=RANDOM_STATE
&resource=https://fencingoffice.com/api/mcpresponse_typemust becode, andcode_challenge_methodmust beS256.scopeisread, orread writeto ask for changes. Without it you getread.resourceis optional; when sent it must behttps://fencingoffice.com/api/mcp(orhttps://fencingoffice.com).statecomes back as you sent it (up to 500 characters).
The admin signs in, picks one of the clubs where they are an admin, and picks what the app may do. “Look things up” is chosen by default; changes are offered only when the app asked for write. On yes, the browser goes back to your redirect with a one-time code (good for 10 minutes), your state, and the issuer (RFC 9207):
https://clubstats.example.com/oauth/callback?code=…&state=RANDOM_STATE&iss=https://fencingoffice.comOn no, it goes back with error=access_denied instead of a code.
4. Swap the code for tokens
Client ids start with foc_ and refresh tokens with for_. Form-encoded (JSON also works). Send client_secret in the body or as HTTP Basic if you registered for one.
curl https://fencingoffice.com/api/oauth/token \
-d grant_type=authorization_code \
-d code=THE_CODE \
-d redirect_uri=https://clubstats.example.com/oauth/callback \
-d client_id=CLIENT_ID \
-d code_verifier=THE_VERIFIER{
"access_token": "fo_live_…",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "for_…",
"scope": "read write"
}- The access token is a club key that lapses after an hour (
expires_in: 3600). Use it exactly like an API key, on the REST API or the MCP server. - A
writetoken can make changes, and each change still asks first with a confirm token. - If you send
resourcehere, it must be this server, as above.
5. Refresh
curl https://fencingoffice.com/api/oauth/token \
-d grant_type=refresh_token \
-d refresh_token=THE_REFRESH_TOKEN \
-d client_id=CLIENT_ID- Every refresh returns a new access token and a new refresh token; keep the newest of both. The access tokens issued before stop working at once. Using an old refresh token again ends the whole grant, since it may have been stolen.
- A refresh token unused for 90 days stops working, and the admin signs in again.
- A refresh also checks the admin is still an admin of that club with the same member record. If not, the grant ends.
6. Sign out
Hand back the refresh token to end the app’s access (RFC 7009). The reply is always 200.
curl https://fencingoffice.com/api/oauth/revoke -d token=THE_REFRESH_TOKENAdmins see every signed-in app under SettingsConnections, with whether it may make changes, which admin connected it and when it was last used. Disconnect ends its tokens at once.
Errors
The register, token and revoke endpoints answer errors in the OAuth format, with CORS open so browser-based clients can call them:
{ "error": "invalid_grant", "error_description": "…" }| error | When |
|---|---|
invalid_request | A missing or malformed field, PKCE missing, or a resource that isn’t this server. |
invalid_client | Unknown client_id (status 401), or a wrong or missing client secret. |
invalid_grant | A code that is used, expired or for another app or redirect; a wrong PKCE verifier; a refresh token that is unknown, reused or idle too long. |
unsupported_grant_type | Anything but authorization_code or refresh_token. |
invalid_redirect_uri | A redirect the rules above don’t allow, or one the app didn’t register. |
invalid_client_metadata | A registration asking for another auth method or grant type. |
server_error | Something failed on our side. Try again. |
Apps that already sign in
Claude, ChatGPT, Cursor, VS Code, Claude Code, Gemini CLI and Codex do all of this by themselves from the server URL. Connect an AI app.