Authentication
Installation tokens for club apps, OAuth 2.1 with PKCE for member apps, token types and limits
Apps never sign in with a person's password or API key. They get short-lived tokens bound to an installation. How depends on the installation type:
| Club app | Member app | |
|---|---|---|
| Flow | Client credentials against an installation | OAuth 2.1 authorization code with PKCE |
| Endpoint | POST /api/oauth/installation-token | GET /api/oauth/authorize, POST /api/oauth/token |
| Access token | cca_…, 1 hour | ccu_…, 1 hour |
| Renewal | Just fetch a new one | Refresh token ccr_… with rotation |
| Club | Fixed | Via X-Tenant-Id if there are several |
All URLs start with https://app.checkcourt.de.
Client credentials
Every app has a public client ID (cca_app_…) and up to five active
client secrets (ccas_…). You find both in the developer portal on the
Zugangsdaten (credentials) tab. A secret is shown only when it is created.
All token endpoints expect the client ID and secret, either
- via HTTP Basic:
Authorization: Basic base64(client_id:client_secret), with both parts URL-encoded first (RFC 6749, section 2.3.1), or - in the body as
client_idandclient_secret.
Bodies can be application/x-www-form-urlencoded or JSON. Basic credentials take
precedence over the fields in the body.
CheckCourt supports only confidential clients: the secret lives on your server, never in a browser or mobile app. A pure single-page app without a backend therefore can't sign in.
Rotate a secret
When a secret is revoked, all access tokens fetched with it become invalid immediately. Refresh tokens survive. So rotate like this:
- Create a new secret and store it in your service.
- Member apps: refresh once with the new secret.
- Remove the old secret with Widerrufen (revoke).
Installation token
For club apps, you exchange the client ID, secret and installation ID for an access
token. You get the installation ID with the app.installed event.
curl -X POST "https://app.checkcourt.de/api/oauth/installation-token" \
-u "$CHECKCOURT_CLIENT_ID:$CHECKCOURT_CLIENT_SECRET" \
-d "installation_id=inst_…"const res = await fetch("https://app.checkcourt.de/api/oauth/installation-token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
client_id: process.env.CHECKCOURT_CLIENT_ID,
client_secret: process.env.CHECKCOURT_CLIENT_SECRET,
installation_id: "inst_…",
}),
});
const { access_token, expires_in, scopes } = await res.json();installationAuth fetches the token, caches it and fetches a new one 60 seconds before it
expires (more):
import { createCheckCourtClient, installationAuth } from "@checkcourt/sdk";
const client = createCheckCourtClient({
auth: installationAuth({
clientId: process.env.CHECKCOURT_CLIENT_ID!,
clientSecret: process.env.CHECKCOURT_CLIENT_SECRET!,
installationId: "inst_…",
}),
});{
"access_token": "cca_…",
"token_type": "Bearer",
"expires_in": 3600,
"installation_id": "inst_…",
"scopes": ["bookings:read", "courts:read"]
}- The token is valid for one hour and only for this installation. There is no refresh token: fetch a new one well before it expires. Several valid tokens per installation are fine.
scopesare the permissions the token has right now (see intersections).- Only club installations get installation tokens. Member connections use OAuth.
Errors
| HTTP | error | Meaning |
|---|---|---|
| 400 | invalid_request | Body missing, or client_id, client_secret or installation_id missing |
| 401 | invalid_client | Wrong credentials, or the installation doesn't belong to your app, is paused or removed, or your app is suspended |
| 429 | slow_down | Too many requests |
Errors use the RFC 6749 format:
{ "error": "invalid_client", "error_description": "Zugangsdaten oder Installation ungültig" }There is deliberately only one response for "wrong secret" and "installation paused or
not yours". This way nobody can find out which installations exist. If you keep getting
invalid_client for one installation while others work, the club has probably paused or
removed it.
OAuth for member apps
Member apps and user links use the OAuth 2.1 authorization code flow. PKCE with S256 is
required, plain is not supported.
Register a redirect URL
On the Zugangsdaten tab, under Weiterleitungsadressen (redirect URLs), enter every
URL CheckCourt may redirect to after consent, one per line, at most ten. Only https
URLs without a # fragment and without credentials are allowed. For development,
http://localhost, 127.0.0.1 and [::1] are accepted too; on app.checkcourt.de they
work only for your sandbox club and block review (see
What is allowed where). Matching is exact: case,
trailing slash and query must match.
Create the code verifier and challenge
import crypto from "node:crypto";
const verifier = crypto.randomBytes(32).toString("base64url"); // 43 characters
const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");
const state = crypto.randomBytes(16).toString("base64url");
// store verifier and state in the user's sessionThe verifier must be 43 to 128 characters from A-Z a-z 0-9 - . _ ~.
Redirect to consent
https://app.checkcourt.de/api/oauth/authorize
?response_type=code
&client_id=cca_app_…
&redirect_uri=https%3A%2F%2Fkalender.example.de%2Fcallback
&code_challenge=<challenge>
&code_challenge_method=S256
&state=<state>| Parameter | Required | Meaning |
|---|---|---|
response_type | yes | Always code |
client_id | yes | Your client ID |
redirect_uri | yes | One of the registered URLs, exactly |
code_challenge | yes | Base64url of the SHA-256 of the verifier, without padding |
code_challenge_method | yes | Always S256 |
state | recommended | Returned unchanged, at most 500 characters |
tenant | no | Club ID preselected in the dialog |
prompt | no | consent shows the consent dialog even if consent was already given |
You don't need a scope parameter. The permissions come from userScopes in the manifest.
If the member is not signed in, they sign in first and then return to the dialog.
The member consents
The consent dialog shows your app's name, icon and provider (with the badge Verifizierter Anbieter (verified provider) if CheckCourt has reviewed your organization), the permissions in German, the data processing and, if your app reads bookings, teams, members or similar, explicitly which data about other people it sees. Under Für welche Vereine? (for which clubs?) the member picks one or more of their clubs. Clubs where the app is not allowed appear with a reason and can't be selected.
Each selected club gets its own connection (installation), each with app.installed sent
to your app.
No dialog for known consent: if the member has already connected the app in every club
where it is allowed, and the current versions are running there, CheckCourt redirects back
with a code right away, without a dialog. Use prompt=consent to force the dialog, for
example so the member can add another club.
Return to your app
After Zulassen (allow), CheckCourt redirects back:
https://kalender.example.de/callback?code=ccc_…&state=<state>Check state. The code is valid for 60 seconds and only once. After Ablehnen
(decline) you get ?error=access_denied&error_description=…&state=….
Exchange the code for tokens
curl -X POST "https://app.checkcourt.de/api/oauth/token" \
-u "$CHECKCOURT_CLIENT_ID:$CHECKCOURT_CLIENT_SECRET" \
-d grant_type=authorization_code \
-d code=ccc_… \
--data-urlencode redirect_uri=https://kalender.example.de/callback \
-d code_verifier=<verifier>const basic = Buffer.from(
`${encodeURIComponent(CLIENT_ID)}:${encodeURIComponent(CLIENT_SECRET)}`,
).toString("base64");
const res = await fetch("https://app.checkcourt.de/api/oauth/token", {
method: "POST",
headers: {
Authorization: `Basic ${basic}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: "https://kalender.example.de/callback",
code_verifier: verifier,
}),
});
const tokens = await res.json();The SDK creates the PKCE pair, builds the URL and exchanges the code
(full flow). userAuth then handles refresh with rotation:
import { exchangeCode, parseAuthorizeCallback } from "@checkcourt/sdk";
const { code } = parseAuthorizeCallback(callbackUrl, savedState);
const tokens = await exchangeCode({
clientId: CLIENT_ID,
clientSecret: CLIENT_SECRET,
code,
redirectUri: "https://kalender.example.de/callback",
codeVerifier: verifier,
});
// tokens.accessToken, tokens.refreshToken, tokens.expiresAt, tokens.installations{
"access_token": "ccu_…",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "ccr_…",
"scope": "bookings:read teams:read",
"installations": [
{ "installation_id": "inst_…", "tenant_id": "…" },
{ "installation_id": "inst_…", "tenant_id": "…" }
]
}installations lists every active connection between the member and your app, that is,
the clubs where you can use the token. scope is the union of the userScopes of these
connections. For which permissions actually apply in a club, see
intersections.
Refresh tokens
curl -X POST "https://app.checkcourt.de/api/oauth/token" \
-u "$CHECKCOURT_CLIENT_ID:$CHECKCOURT_CLIENT_SECRET" \
-d grant_type=refresh_token \
-d refresh_token=ccr_…The response has the same format as above, with a new refresh token. Note:
- Rotation: each refresh token works exactly once. Always store the new one.
- Reuse is detected: if an already used refresh token or an already redeemed code is presented again, the whole chain is considered compromised. CheckCourt then revokes all tokens from this consent. This also applies to two parallel refreshes with the same token: per member, only refresh from one place at a time.
- Lifetime: a refresh token is valid for 30 days. A chain lives at most 180 days from
consent. After that CheckCourt responds with
invalid_grant, and the member has to authorize the app again. If they are still connected everywhere, this usually happens without a dialog.
temporarily_unavailable
A refresh can return 400 with error: "temporarily_unavailable". This is a CheckCourt
extension and means: the consent is still valid, but nothing may run right now. Possible
reasons:
- the club has paused the connection or blocked member apps
- the app is suspended or no longer available to the club
- the member's account is locked or their membership is awaiting approval
In this case the refresh token is not consumed. Try again later with the same token. Ideally, show the member a notice like "The connection to CheckCourt is currently paused" instead of making them sign in again.
invalid_grant, on the other hand, means: the token is dead (expired, revoked, reused,
chain older than 180 days) or the member has disconnected the app everywhere. Only a new
authorization helps.
Revoke tokens
curl -X POST "https://app.checkcourt.de/api/oauth/revoke" \
-u "$CHECKCOURT_CLIENT_ID:$CHECKCOURT_CLIENT_SECRET" \
-d token=ccr_…With valid client authentication, CheckCourt always responds with 200, even if the token
was unknown (RFC 7009). A revoked refresh token takes its whole chain with it, an access
token only itself. This doesn't disconnect the app from CheckCourt: only the member, under
Einstellungen → Verbundene Apps (Settings → Connected apps), or the club can end the
connection.
Token endpoint errors
| HTTP | error | Meaning |
|---|---|---|
| 400 | invalid_request | Required field missing |
| 400 | unsupported_grant_type | Only authorization_code and refresh_token |
| 400 | invalid_grant | Code or refresh token invalid, expired or already used, redirect_uri or code_verifier doesn't match, app no longer connected |
| 400 | temporarily_unavailable | See above, only when refreshing |
| 401 | invalid_client | Client authentication failed |
| 429 | slow_down | Too many requests |
Authorization errors
If client_id is unknown or redirect_uri is not registered, CheckCourt shows its own
error page and does not redirect, so nobody can send members to foreign URLs. All
other errors are sent by redirect to your redirect_uri:
error | Meaning |
|---|---|
invalid_request | PKCE missing or not S256, or state too long |
unsupported_response_type | response_type is not code |
unauthorized_client | The app is suspended, has no manifest yet, or doesn't connect member accounts |
access_denied | The member declined |
If there is no club where the member may connect the app, they see "Diese App ist für dich nicht verfügbar" (this app is not available to you) instead of the dialog. If the app is published in the marketplace, the dialog appears anyway, with the notice "In keinem deiner Vereine lässt sich die App gerade verbinden." (the app can't be connected in any of your clubs right now).
Rate limits
| Endpoint | Per IP address | Per app or token |
|---|---|---|
POST /api/oauth/installation-token | 30 per minute | 120 per minute per app |
POST /api/oauth/token | 60 per minute | 300 per minute per app |
POST /api/oauth/revoke | 60 per minute | 300 per minute per app |
/api/v1/* | 60 per minute | 120 per minute per credential |
For /api/v1/*, the credential counts like this: with cca_, each installation on its
own, all tokens of the same installation together; with ccu_, the member app per
member, across all clubs.
Failed sign-ins are also counted per client ID and IP address (20 per minute). This way,
someone who knows your public client ID can't lock your app out. CheckCourt answers
excess requests with 429. For /api/v1/*, the Retry-After header tells you how many
seconds to wait; otherwise wait one minute.
Token prefixes
| Prefix | What | Where to use |
|---|---|---|
cca_app_ | Client ID (public) | Token endpoints, authorization |
ccas_ | Client secret | Only on your server, at token endpoints |
cca_ | Access token of a club installation | Authorization: Bearer at /api/v1 |
ccu_ | Access token of a member | Authorization: Bearer at /api/v1 |
ccr_ | Refresh token | Only at /api/oauth/token and /api/oauth/revoke. Always rejected as a bearer token |
ccc_ | Authorization code | Once at /api/oauth/token |
whsec_ | Signing secret | Verifying webhooks and UI extensions |
ccil_ | Installation link | Part of the URL /admin/apps/link/… |
The prefixes make secrets recognizable, for example for secret scanning in your repository.
Choose the club (X-Tenant-Id)
- Installation tokens (
cca_) belong to exactly one club. You don't needX-Tenant-Id. If you send it with a different club ID, the API responds with403 FORBIDDEN. - Member tokens (
ccu_) are valid for all clubs where the member has connected your app. If there is only one, it is used automatically. If there are several,X-Tenant-Idis required, otherwise you get400 VALIDATION. For a club without a connection you get403 FORBIDDEN.
With a ccu_ token, GET /api/v1/me/tenants returns the clubs where the connection is
currently active, with id and name:
curl "https://app.checkcourt.de/api/v1/me/tenants" -H "Authorization: Bearer ccu_…"{ "tenants": [{ "id": "…", "name": "TC Musterstadt" }] }Scopes are intersections
A token never has more permissions than all parties allow:
- Club app: the scopes granted at installation, intersected with the
tenantScopesof the installed version. Whoever installs must hold these permissions themselves at that moment. After that, the installation belongs to the club: if the treasurer changes, for example, the app keeps running. - Member app: the granted scopes, intersected with the
userScopesof the version and with the permissions the member holds in the club today. If they lose a role, the app loses the permission immediately. If they are no longer a member, are locked, or their membership is awaiting approval, the token doesn't work in this club. - Scopes blocked for apps never apply, even if they were granted somewhere.
What apps can never do
- Use the scopes blocked for apps: see or assign roles, manage apps or webhook endpoints, create API keys, see the subscription, sign a data processing agreement (AVV).
- See more than the UI: all app tokens are subject to the visibility rules members see in the booking calendar. See API for apps.
- See hidden roles or filter by them.
- Touch member accounts: change the login email address, set passwords or delete members.
This applies to club and member apps, even with
members:writeormembers:delete; the API then responds with 403. Resetting two-factor authentication, ending sessions and locking accounts is available only to CheckCourt support. - Install apps, enable them or accept installation links. A signed-in person always does that.
- Create their own bookings under
/me/*if it is a club app: it has no identity of its own. For the same reason, club apps don't manage teams.