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

  1. Open Connections

    As a club admin, go to SettingsConnections and scroll to Keys, for apps that can’t sign in.
  2. 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.
  3. 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.
KeyWhat it can do
ReadEvery 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_ or fo_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: true and changed: false with 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 on 127.0.0.1, localhost or [::1] (any port, for desktop apps), or an app’s own scheme like cursor://. No fragments and no user names or passwords in them.
  • grant_types may be authorization_code and refresh_token only.
  • 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_uri and logo_uri are 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/mcp
  • response_type must be code, and code_challenge_method must be S256.
  • scope is read, or read write to ask for changes. Without it you get read.
  • resource is optional; when sent it must be https://fencingoffice.com/api/mcp (or https://fencingoffice.com).
  • state comes 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.com

On 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 write token can make changes, and each change still asks first with a confirm token.
  • If you send resource here, 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_TOKEN

Admins 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": "…" }
errorWhen
invalid_requestA missing or malformed field, PKCE missing, or a resource that isn’t this server.
invalid_clientUnknown client_id (status 401), or a wrong or missing client secret.
invalid_grantA 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_typeAnything but authorization_code or refresh_token.
invalid_redirect_uriA redirect the rules above don’t allow, or one the app didn’t register.
invalid_client_metadataA registration asking for another auth method or grant type.
server_errorSomething 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.