CheckCourt
App platform

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 appMember app
FlowClient credentials against an installationOAuth 2.1 authorization code with PKCE
EndpointPOST /api/oauth/installation-tokenGET /api/oauth/authorize, POST /api/oauth/token
Access tokencca_…, 1 hourccu_…, 1 hour
RenewalJust fetch a new oneRefresh token ccr_… with rotation
ClubFixedVia 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_id and client_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:

  1. Create a new secret and store it in your service.
  2. Member apps: refresh once with the new secret.
  3. 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_…",
  }),
});
200 OK
{
  "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.
  • scopes are the permissions the token has right now (see intersections).
  • Only club installations get installation tokens. Member connections use OAuth.

Errors

HTTPerrorMeaning
400invalid_requestBody missing, or client_id, client_secret or installation_id missing
401invalid_clientWrong credentials, or the installation doesn't belong to your app, is paused or removed, or your app is suspended
429slow_downToo 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 session

The verifier must be 43 to 128 characters from A-Z a-z 0-9 - . _ ~.

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>
ParameterRequiredMeaning
response_typeyesAlways code
client_idyesYour client ID
redirect_uriyesOne of the registered URLs, exactly
code_challengeyesBase64url of the SHA-256 of the verifier, without padding
code_challenge_methodyesAlways S256
staterecommendedReturned unchanged, at most 500 characters
tenantnoClub ID preselected in the dialog
promptnoconsent 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
200 OK
{
  "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

HTTPerrorMeaning
400invalid_requestRequired field missing
400unsupported_grant_typeOnly authorization_code and refresh_token
400invalid_grantCode or refresh token invalid, expired or already used, redirect_uri or code_verifier doesn't match, app no longer connected
400temporarily_unavailableSee above, only when refreshing
401invalid_clientClient authentication failed
429slow_downToo 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:

errorMeaning
invalid_requestPKCE missing or not S256, or state too long
unsupported_response_typeresponse_type is not code
unauthorized_clientThe app is suspended, has no manifest yet, or doesn't connect member accounts
access_deniedThe 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

EndpointPer IP addressPer app or token
POST /api/oauth/installation-token30 per minute120 per minute per app
POST /api/oauth/token60 per minute300 per minute per app
POST /api/oauth/revoke60 per minute300 per minute per app
/api/v1/*60 per minute120 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

PrefixWhatWhere to use
cca_app_Client ID (public)Token endpoints, authorization
ccas_Client secretOnly on your server, at token endpoints
cca_Access token of a club installationAuthorization: Bearer at /api/v1
ccu_Access token of a memberAuthorization: Bearer at /api/v1
ccr_Refresh tokenOnly at /api/oauth/token and /api/oauth/revoke. Always rejected as a bearer token
ccc_Authorization codeOnce at /api/oauth/token
whsec_Signing secretVerifying webhooks and UI extensions
ccil_Installation linkPart 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 need X-Tenant-Id. If you send it with a different club ID, the API responds with 403 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-Id is required, otherwise you get 400 VALIDATION. For a club without a connection you get 403 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 tenantScopes of 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 userScopes of 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:write or members: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.

On this page