> ## Documentation Index
> Fetch the complete documentation index at: https://docs.cyclemate.club/llms.txt
> Use this file to discover all available pages before exploring further.

# Login with Cyclemate

> Let riders sign in to your app with their Cyclemate account and call the API on their behalf

export const LoginWithCyclemateButton = () => {
  const [url, setUrl] = useState(null);
  const [hover, setHover] = useState(null);
  const [pressed, setPressed] = useState(null);
  const titles = ['Sign in with Cyclemate', 'Sign up with Cyclemate', 'Continue with Cyclemate'];
  const base64url = bytes => btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
  const build = async () => {
    const verifierBytes = new Uint8Array(32);
    crypto.getRandomValues(verifierBytes);
    const verifier = base64url(verifierBytes);
    const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
    const challenge = base64url(new Uint8Array(digest));
    const stateBytes = new Uint8Array(16);
    crypto.getRandomValues(stateBytes);
    const params = new URLSearchParams({
      response_type: 'code',
      client_id: 'YOUR_CLIENT_ID',
      redirect_uri: 'https://your-app.example/auth/callback',
      scope: 'openid email',
      state: base64url(stateBytes),
      code_challenge: challenge,
      code_challenge_method: 'S256'
    });
    setUrl('https://api.cyclemate.club/auth/v1/oauth/authorize?' + params.toString());
  };
  return <div className="not-prose" style={{
    margin: '24px 0'
  }}>
      <div style={{
    display: 'flex',
    flexWrap: 'wrap',
    gap: 16
  }}>
        {titles.map(title => <button key={title} type="button" onClick={build} onMouseEnter={() => setHover(title)} onMouseLeave={() => {
    setHover(null);
    setPressed(null);
  }} onMouseDown={() => setPressed(title)} onMouseUp={() => setPressed(null)} style={{
    display: 'inline-flex',
    alignItems: 'center',
    justifyContent: 'center',
    gap: 8,
    minHeight: 50,
    padding: '0 24px',
    borderRadius: 8,
    border: '1px solid ' + (hover === title ? 'rgba(0, 0, 0, 0.2)' : 'rgba(0, 0, 0, 0.15)'),
    background: pressed === title ? '#E0E0E0' : hover === title ? '#F0F0F0' : '#FFFFFF',
    color: '#000000',
    fontFamily: 'Fustat, system-ui, sans-serif',
    fontSize: 16,
    fontWeight: 700,
    letterSpacing: 0.6,
    lineHeight: 1.2,
    cursor: 'pointer',
    transform: pressed === title ? 'scale(0.97)' : 'none',
    transition: 'background 120ms ease, border-color 120ms ease, transform 120ms ease'
  }}>
            <img src="/images/brand/cyclemate-icon.png" width="20" height="20" alt="" style={{
    borderRadius: 4,
    display: 'block'
  }} />
            {title}
          </button>)}
      </div>
      <div style={{
    fontSize: 14,
    opacity: 0.7,
    marginTop: 12,
    lineHeight: 1.5
  }}>
        {url ? 'In your app this button would open the URL below. Every click makes a new code challenge and state.' : 'Click a button to see the authorize URL it would open.'}
      </div>
      {url ? <pre style={{
    marginTop: 12,
    padding: 14,
    borderRadius: 12,
    border: '1px solid rgba(148, 163, 184, 0.4)',
    fontSize: 12,
    lineHeight: 1.6,
    whiteSpace: 'pre-wrap',
    wordBreak: 'break-all'
  }}>
          {url}
        </pre> : null}
    </div>;
};

## Overview

Login with Cyclemate is OAuth 2.1 with PKCE. A rider taps a button in your app, signs in to Cyclemate (or is already signed in), approves your app on our consent screen, and your app receives an access token for that rider. Requests made with the token count against the rider's own rate limit and are attributed to your app.

An access token works on these endpoints, and nothing else:

| Endpoint | What your app can do as the rider |
| - | - |
| [`POST /directions/v2/`](/api-reference/routing/directions) | Plan SuperSafe cycling and walking routes |
| [`POST /multi-modal/v2/`](/api-reference/routing/multimodal) | Plan routes with bike share and parking |
| [`POST /explain-supersafe/`](/api-reference/routing/explain-supersafe) | Explain why a SuperSafe route differs from the direct one |
| [`POST /reports/`](/api-reference/reports/submit-report) | File a road report in the rider's name |
| [`GET /reports/{id}/`](/api-reference/reports/get-report) | Read the status of a report the rider filed |

A token never reads the rider's ride history, saved routes, profile or settings, and it cannot reach any other part of the platform. Choose this over an [API key](/api-reference/introduction#authentication) when your app acts for a signed-in rider. A key is for your own server calling the API as itself.

## Register your app

Email [support@cyclemate.club](mailto:support@cyclemate.club) with:

* The name of your app, as riders should see it on the consent screen.
* Every redirect URI your app will use, in full. Matching is exact and there are no wildcards; see [Redirect URIs](#redirect-uris).
* Whether the app is **public** (a mobile app or a single-page web app, which cannot keep a secret) or **confidential** (a server-side app that can).

We reply with your client ID. A confidential app also receives a client secret, shown once; we can issue a new one if it is lost. Use separate registrations for development and production.

## Redirect URIs

A redirect URI is the page or screen in your app that receives the rider after consent. We only ever send riders to a URI you registered, and the one you send in the authorize request must match a registered one **exactly**: scheme, host, port and path, character for character. There are no wildcards, patterns or prefix matches.

| Register | Not | Why |
| - | - | - |
| `https://your-app.example/auth/callback` | `https://your-app.example/auth/callback/` | A trailing slash is a different path |
| `https://your-app.example/auth/callback` | `https://www.your-app.example/auth/callback` | A different host |
| `https://your-app.example:8443/auth/callback` | `https://your-app.example/auth/callback` | The port is part of the match |
| `http://localhost:3000/auth/callback` | `http://localhost/auth/callback` | Local development, with its port |
| `com.yourcompany.rides://oauth/callback` | `com.yourcompany.rides://` | A custom scheme still needs the full path |

What to register:

* **Web apps:** `https://` URIs only. The one exception is `http://localhost:<port>/…` for local development.
* **Mobile apps:** either your app's custom scheme (`com.yourcompany.rides://oauth/callback`) or an `https://` URL your app claims as a universal link or app link. Open the authorize URL in the system browser or an in-app browser tab; a web view will not hand the redirect back to your app.
* **Every environment you run:** register one client per environment (development, staging, production) rather than one client with every URI. Each gets its own client ID and secret, so a leaked development secret never reaches production.
* **Several callbacks in one app** (a silent-refresh page, a native and a web callback): list them all. Each authorize request names the one it wants.

What we send to it:

* On approval, `?code=…&state=…`. Exchange the code within 10 minutes; it works once.
* On a cancel, `?error=access_denied&error_description=…&state=…`.
* Nothing, if the request was malformed: an unregistered `redirect_uri`, an unknown `client_id` or a missing PKCE challenge answers the rider's browser with an error straight from the authorize endpoint and never redirects anywhere, so a typo in the URI surfaces as a rider stuck on that error rather than as a request to your app.

Two URLs you do not register: the consent screen itself (`https://cyclemate.club/oauth/consent`) is ours, and the authorize and token endpoints are fixed (see [Endpoints](#endpoints)). To add, remove or change a redirect URI after registration, email [support@cyclemate.club](mailto:support@cyclemate.club); the change applies to new authorize requests immediately.

## The button

<LoginWithCyclemateButton />

One look, three titles. Use **Sign in** where a rider has an account, **Sign up** where they are creating one, and **Continue** where you do not know. Keep the mark, the title and the colours as they are; size the button to fit your layout. It sits on light and dark pages alike.

| Property | Value |
| - | - |
| Typeface | Fustat, 16px, bold (700), 0.6px letter spacing. Fustat is a free Google Font; the snippet loads it |
| Size | 50px tall, 24px side padding, 8px corner radius |
| Colours | White background, black title, 1px border at 15% black. Hover `#F0F0F0` with the border at 20% black. Pressed `#E0E0E0` at 97% scale |
| Mark | The Cyclemate icon at 20px with 4px rounded corners, 8px before the title. Load it from `https://docs.cyclemate.club/images/brand/cyclemate-icon.png` or ship a copy |

Build the authorize URL when the rider taps, so each attempt gets a fresh `code_challenge` and `state`.

<CodeGroup>
  ```html HTML theme={null}
  <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fustat:wght@700&display=swap">
  <style>
    .login-with-cyclemate {
      display: inline-flex; align-items: center; justify-content: center; gap: 8px;
      min-height: 50px; padding: 0 24px; border-radius: 8px;
      border: 1px solid rgba(0, 0, 0, 0.15); background: #FFFFFF; color: #000000;
      font: 700 16px/1.2 Fustat, system-ui, sans-serif; letter-spacing: 0.6px;
      text-decoration: none; cursor: pointer;
      transition: background 120ms ease, border-color 120ms ease, transform 120ms ease;
    }
    .login-with-cyclemate:hover { background: #F0F0F0; border-color: rgba(0, 0, 0, 0.2); }
    .login-with-cyclemate:active { background: #E0E0E0; transform: scale(0.97); }
    .login-with-cyclemate img { width: 20px; height: 20px; border-radius: 4px; display: block; }
  </style>

  <a id="login-with-cyclemate" class="login-with-cyclemate" href="#">
    <img src="https://docs.cyclemate.club/images/brand/cyclemate-icon.png" alt="">
    Sign in with Cyclemate
  </a>

  <script type="module">
    const base64url = (bytes) =>
      btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')

    async function authorizeUrl() {
      const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)))
      const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))
      const state = base64url(crypto.getRandomValues(new Uint8Array(16)))
      sessionStorage.setItem('cyclemate_code_verifier', verifier)
      sessionStorage.setItem('cyclemate_state', state)
      const params = new URLSearchParams({
        response_type: 'code',
        client_id: 'YOUR_CLIENT_ID',
        redirect_uri: 'https://your-app.example/auth/callback',
        scope: 'openid email',
        state,
        code_challenge: base64url(new Uint8Array(digest)),
        code_challenge_method: 'S256',
      })
      return 'https://api.cyclemate.club/auth/v1/oauth/authorize?' + params
    }

    document.getElementById('login-with-cyclemate').addEventListener('click', async (e) => {
      e.preventDefault()
      window.location.assign(await authorizeUrl())
    })
  </script>
  ```

  ```jsx React theme={null}
  import { useCallback, useState } from 'react'

  const base64url = (bytes) =>
    btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')

  async function authorizeUrl({ clientId, redirectUri, scope = 'openid email' }) {
    const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)))
    const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))
    const state = base64url(crypto.getRandomValues(new Uint8Array(16)))
    sessionStorage.setItem('cyclemate_code_verifier', verifier)
    sessionStorage.setItem('cyclemate_state', state)
    const params = new URLSearchParams({
      response_type: 'code',
      client_id: clientId,
      redirect_uri: redirectUri,
      scope,
      state,
      code_challenge: base64url(new Uint8Array(digest)),
      code_challenge_method: 'S256',
    })
    return `https://api.cyclemate.club/auth/v1/oauth/authorize?${params}`
  }

  const ICON = 'https://docs.cyclemate.club/images/brand/cyclemate-icon.png'
  const surface = { base: '#FFFFFF', hover: '#F0F0F0', pressed: '#E0E0E0' }
  const border = { base: 'rgba(0, 0, 0, 0.15)', hover: 'rgba(0, 0, 0, 0.2)' }

  export function LoginWithCyclemate({ clientId, redirectUri, title = 'Sign in with Cyclemate' }) {
    const [hover, setHover] = useState(false)
    const [pressed, setPressed] = useState(false)
    const onClick = useCallback(async () => {
      window.location.assign(await authorizeUrl({ clientId, redirectUri }))
    }, [clientId, redirectUri])

    return (
      <button
        type="button"
        onClick={onClick}
        onMouseEnter={() => setHover(true)}
        onMouseLeave={() => { setHover(false); setPressed(false) }}
        onMouseDown={() => setPressed(true)}
        onMouseUp={() => setPressed(false)}
        style={{
          display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 8,
          minHeight: 50, padding: '0 24px', borderRadius: 8,
          border: `1px solid ${hover ? border.hover : border.base}`,
          background: pressed ? surface.pressed : hover ? surface.hover : surface.base,
          color: '#000000',
          font: '700 16px/1.2 Fustat, system-ui, sans-serif', letterSpacing: 0.6,
          cursor: 'pointer',
          transform: pressed ? 'scale(0.97)' : 'none',
          transition: 'background 120ms ease, border-color 120ms ease, transform 120ms ease',
        }}
      >
        <img src={ICON} width="20" height="20" alt="" style={{ borderRadius: 4, display: 'block' }} />
        {title}
      </button>
    )
  }
  ```

  ```ts Callback (browser) theme={null}
  // On your redirect URI: check the state, then exchange the code (public app).
  const params = new URLSearchParams(window.location.search)
  if (params.get('state') !== sessionStorage.getItem('cyclemate_state')) {
    throw new Error('state mismatch')
  }
  if (params.get('error')) {
    // 'access_denied' when the rider cancelled; show your signed-out state.
  }

  const response = await fetch('https://api.cyclemate.club/auth/v1/oauth/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      code: params.get('code'),
      client_id: 'YOUR_CLIENT_ID',
      redirect_uri: 'https://your-app.example/auth/callback',
      code_verifier: sessionStorage.getItem('cyclemate_code_verifier'),
    }),
  })
  const tokens = await response.json()
  // tokens.access_token, tokens.refresh_token, tokens.expires_in
  ```
</CodeGroup>

### Ready-made buttons

If you would rather not build the button, download one. Each file is the button above with the title already drawn, so there is nothing to load. The SVG scales to any size; the PNGs are 50px tall at 1x.

| Preview | SVG | PNG |
| - | - | - |
| <img src="https://mintcdn.com/cyclemate/9a_wZdpVeWy_pM_x/images/login-with-cyclemate/sign-in.svg?fit=max&auto=format&n=9a_wZdpVeWy_pM_x&q=85&s=0676db6fe35121cb9e636b088fa03289" alt="Sign in with Cyclemate" height="50" data-path="images/login-with-cyclemate/sign-in.svg" /> | [sign-in.svg](/images/login-with-cyclemate/sign-in.svg) | [1x](/images/login-with-cyclemate/sign-in.png) · [2x](/images/login-with-cyclemate/sign-in@2x.png) · [3x](/images/login-with-cyclemate/sign-in@3x.png) |
| <img src="https://mintcdn.com/cyclemate/9a_wZdpVeWy_pM_x/images/login-with-cyclemate/sign-up.svg?fit=max&auto=format&n=9a_wZdpVeWy_pM_x&q=85&s=55a884446a3a94c94cdebfefb3497e7a" alt="Sign up with Cyclemate" height="50" data-path="images/login-with-cyclemate/sign-up.svg" /> | [sign-up.svg](/images/login-with-cyclemate/sign-up.svg) | [1x](/images/login-with-cyclemate/sign-up.png) · [2x](/images/login-with-cyclemate/sign-up@2x.png) · [3x](/images/login-with-cyclemate/sign-up@3x.png) |
| <img src="https://mintcdn.com/cyclemate/9a_wZdpVeWy_pM_x/images/login-with-cyclemate/continue.svg?fit=max&auto=format&n=9a_wZdpVeWy_pM_x&q=85&s=5811e29ad36ccfdfb2459bbdd742ae27" alt="Continue with Cyclemate" height="50" data-path="images/login-with-cyclemate/continue.svg" /> | [continue.svg](/images/login-with-cyclemate/continue.svg) | [1x](/images/login-with-cyclemate/continue.png) · [2x](/images/login-with-cyclemate/continue@2x.png) · [3x](/images/login-with-cyclemate/continue@3x.png) |

Scale the image proportionally, keep at least 8px clear around it, and do not recolour, stretch or add a shadow. Wrap it in a link or button that opens the authorize URL, as in the snippets above.

## The flow

<Steps>
  <Step title="Send the rider to the authorize endpoint">
    Open `https://api.cyclemate.club/auth/v1/oauth/authorize` in the rider's browser with these query parameters.

    | Parameter | Value |
    | - | - |
    | `response_type` | `code` |
    | `client_id` | Your client ID |
    | `redirect_uri` | One of your registered [redirect URIs](#redirect-uris), character for character |
    | `code_challenge` | Base64url of the SHA-256 of a random `code_verifier` (43 to 128 characters) |
    | `code_challenge_method` | `S256` |
    | `state` | A random string you store and check on return |
    | `scope` | Space-separated [scopes](#scopes). Defaults to `email` |

    On a phone, open it in the system browser or an in-app browser tab, not a web view. A rider with the Cyclemate app installed may be taken into the app for the consent step; the web page handles it otherwise.
  </Step>

  <Step title="The rider signs in and approves your app">
    We show a consent screen naming your app and what it will be able to do. A rider who is not signed in signs in first (Google, Apple or email), then returns to the same consent screen.

    <Frame caption="The consent screen, here for an app registered as Commute Planner">
      <img src="https://mintcdn.com/cyclemate/9a_wZdpVeWy_pM_x/images/oauth-consent.png?fit=max&auto=format&n=9a_wZdpVeWy_pM_x&q=85&s=4ee85ec79226650c6e069934ed8aed63" alt="The Sign in with Cyclemate consent screen: Commute Planner wants to use your Cyclemate account, the three things the app will be able to do, an Allow button and a Cancel button" width="900" height="760" data-path="images/oauth-consent.png" />
    </Frame>

    The request is valid for 3 minutes. If the rider takes longer, the screen says the request expired and they go back to your app to try again.
  </Step>

  <Step title="Receive the code">
    On approval the rider lands on your `redirect_uri` with `code` and `state`:

    ```
    https://your-app.example/auth/callback?code=…&state=…
    ```

    Check that `state` matches what you stored. The code is single-use and valid for 10 minutes.

    If the rider cancels, the same URI receives `error=access_denied` and an `error_description`, plus your `state`.
  </Step>

  <Step title="Exchange the code for tokens">
    `POST https://api.cyclemate.club/auth/v1/oauth/token`, form-encoded.

    <CodeGroup>
      ```bash Public app theme={null}
      curl -X POST https://api.cyclemate.club/auth/v1/oauth/token \
        -H "Content-Type: application/x-www-form-urlencoded" \
        -d grant_type=authorization_code \
        -d code=THE_CODE \
        -d client_id=YOUR_CLIENT_ID \
        -d redirect_uri=https://your-app.example/auth/callback \
        -d code_verifier=THE_CODE_VERIFIER
      ```

      ```bash Confidential app theme={null}
      curl -X POST https://api.cyclemate.club/auth/v1/oauth/token \
        -u "YOUR_CLIENT_ID:YOUR_CLIENT_SECRET" \
        -H "Content-Type: application/x-www-form-urlencoded" \
        -d grant_type=authorization_code \
        -d code=THE_CODE \
        -d redirect_uri=https://your-app.example/auth/callback \
        -d code_verifier=THE_CODE_VERIFIER
      ```
    </CodeGroup>

    The response:

    ```json theme={null}
    {
      "access_token": "eyJhbGc…",
      "token_type": "bearer",
      "expires_in": 3600,
      "refresh_token": "MXff…",
      "scope": "openid email",
      "id_token": "eyJhbGc…"
    }
    ```

    `id_token` is present only when you requested the `openid` scope.
  </Step>

  <Step title="Call the API as the rider">
    Send the access token as a bearer token. No API key is needed alongside it.

    ```bash theme={null}
    curl -X POST https://api.cyclemate.club/directions/v2/ \
      -H "Authorization: Bearer ACCESS_TOKEN" \
      -H "Content-Type: application/json" \
      -d '{"coordinates": [[-0.1276, 51.5074], [-0.0877, 51.5257]], "profile": "cycling"}'
    ```

    A browser app can call the five endpoints directly: they accept requests from any origin.
  </Step>

  <Step title="Refresh">
    Access tokens last one hour. Refresh before `expires_in` runs out, or when a call returns `401`.

    ```bash theme={null}
    curl -X POST https://api.cyclemate.club/auth/v1/oauth/token \
      -H "Content-Type: application/x-www-form-urlencoded" \
      -d grant_type=refresh_token \
      -d refresh_token=THE_REFRESH_TOKEN \
      -d client_id=YOUR_CLIENT_ID
    ```

    A confidential app authenticates with `-u "YOUR_CLIENT_ID:YOUR_CLIENT_SECRET"` instead of sending `client_id`. Refresh tokens rotate: store the new one whenever the response carries it.
  </Step>
</Steps>

## Endpoints

| Endpoint | URL |
| - | - |
| Authorize | `https://api.cyclemate.club/auth/v1/oauth/authorize` |
| Token | `https://api.cyclemate.club/auth/v1/oauth/token` |
| Signing keys (JWKS) | `https://api.cyclemate.club/auth/v1/.well-known/jwks.json` |

Access tokens are JWTs signed with ES256. We verify every token on our side, so your app does not need to validate them; the JWKS endpoint is there if you want to check an `id_token` yourself.

## Scopes

Scopes only affect what the `id_token` and the rider's identity carry. They never widen what the access token can do on the API.

| Scope | Claims |
| - | - |
| `openid` | Adds an `id_token` to the token response |
| `email` | The rider's email address and whether it is verified. The default |
| `profile` | The rider's name and profile photo |
| `phone` | The rider's phone number, where one is on the account |

## Error responses

### Authorize

Problems with the request itself (an unknown `client_id`, a redirect URI that is not registered, a missing PKCE challenge) are shown to the rider as an error page and never reach your redirect URI. A rider who cancels returns to your redirect URI with `error=access_denied`.

### Token

```json theme={null}
{
  "error": "invalid_grant",
  "error_description": "The authorization code has expired or was already used"
}
```

`invalid_grant` also covers a `code_verifier` that does not match the challenge, and a refresh token that was revoked or already rotated. `invalid_client` means the client ID or secret was wrong.

### API

A valid access token sent to an endpoint outside the five above answers `401` with `{ "error": "OAuth access tokens are not accepted by this endpoint" }`, and the rest of the platform answers `403`. An expired token answers `401` on every endpoint: refresh and retry.

## Good to know

* Rate limits apply per rider, in a bucket of their own for your app, so one busy integration never slows a rider's own use of Cyclemate.
* A report filed with a token is the rider's report. It appears in their app under My reports, and `GET /reports/{id}/` answers only for reports that rider filed.
* To revoke an app's access for every rider, or to rotate a secret, email [support@cyclemate.club](mailto:support@cyclemate.club). Refresh tokens stop working at once; access tokens expire within the hour.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.