This documentation describes how to integrate transitspotter authentication with external websites using OAuth 2.1 with Authorization Code + PKCE flow.
All requests to the External Auth API must include a valid Origin header. This is used for security validation and CORS handling. Requests without a valid Origin header will be rejected.
The authentication flow follows the OAuth 2.1 authorization code flow with PKCE:
Your domain must be approved by transitspotter administrators before you can use this API. Please contact the transitspotter team to get your domain added to the allowed list.
Initiates the authentication flow. Redirect your users to this endpoint.
| Parameter | Required | Description |
|---|---|---|
| response_type | Yes | Must be set to code. |
| client_id | Yes | Your site identifier (typically your domain name) |
| redirect_uri | Yes | The URL to redirect to after successful authentication. Must be from an authorized domain. |
| code_challenge | Yes | The code challenge generated from a code verifier using SHA-256 and base64url encoding. |
| code_challenge_method | Yes | Must be set to S256. |
| state | Recommended | An opaque value used to maintain state between the request and callback. Helps prevent CSRF attacks. |
| scope | No | Space-separated list of scopes. Currently unused, but provided for OAuth 2.1 compliance. |
https://auth.transitspotter.com/external/authorize?response_type=code&client_id=example.com&redirect_uri=https://example.com/callback&code_challenge=CHALLENGE&code_challenge_method=S256&state=random_state_value
Exchanges an authorization code for access and refresh tokens.
| Header | Description |
|---|---|
| Content-Type | application/x-www-form-urlencoded or application/json |
| Origin | The origin of your application (e.g., https://example.com). Must match the redirect_uri origin. |
| Parameter | Required | Description |
|---|---|---|
| grant_type | Yes | Must be set to authorization_code. |
| code | Yes | The authorization code received from the authorize endpoint. |
| client_id | Yes | Your site identifier (same as used in the authorize request). |
| redirect_uri | Yes | The same redirect URI used in the authorize request. |
| code_verifier | Yes | The original code verifier that was used to generate the code challenge. |
| Parameter | Required | Description |
|---|---|---|
| grant_type | Yes | Must be set to refresh_token. |
| refresh_token | Yes | The refresh token previously received. |
| client_id | Yes | Your site identifier. |
| scope | No | Space-separated list of scopes. Must be equal to or a subset of the original scopes. |
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "bearer",
"expires_in": 3600,
"refresh_token": "8a57b597f8d842859a403d7c0294eae35d...",
"scope": ""
}
{
"error": "invalid_grant",
"error_description": "Authorization code has expired"
}
Introspects a token to determine its state and metadata (per RFC 7662).
| Header | Description |
|---|---|
| Content-Type | application/x-www-form-urlencoded or application/json |
| Origin | The origin of your application. Must match the token's intended audience. |
| Parameter | Required | Description |
|---|---|---|
| token | Yes | The token to introspect. |
| token_type_hint | No | A hint about the type of token: access_token or refresh_token. |
{
"active": true,
"client_id": "example.com",
"scope": "",
"token_type": "access_token",
"exp": 1716317154,
"iat": 1716313554,
"sub": "user@example.com"
}
{
"active": false
}
Revokes a token (access or refresh token).
| Header | Description |
|---|---|
| Content-Type | application/x-www-form-urlencoded or application/json |
| Origin | The origin of your application. Must match the token's intended audience. |
| Parameter | Required | Description |
|---|---|---|
| token | Yes | The token to revoke. |
| token_type_hint | No | A hint about the type of token: access_token or refresh_token. |
{
"success": true
}
// OAuth 2.1 with PKCE Implementation
// 1. Generate code verifier and challenge
async function generateCodeVerifierAndChallenge() {
// Generate a random code verifier
const codeVerifier = generateRandomString(64);
// Create a code challenge using SHA-256
const encoder = new TextEncoder();
const data = encoder.encode(codeVerifier);
const digest = await crypto.subtle.digest('SHA-256', data);
// Base64url encode the hash
const codeChallenge = btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
return { codeVerifier, codeChallenge };
}
// Generate a random string for code verifier and state
function generateRandomString(length) {
const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
let result = '';
const randomValues = new Uint8Array(length);
crypto.getRandomValues(randomValues);
for (let i = 0; i < length; i++) {
result += charset[randomValues[i] % charset.length];
}
return result;
}
// 2. Start OAuth flow
async function startOAuthFlow() {
// Generate PKCE values
const { codeVerifier, codeChallenge } = await generateCodeVerifierAndChallenge();
// Generate state for CSRF protection
const state = generateRandomString(32);
// Store in session storage for later use
sessionStorage.setItem('code_verifier', codeVerifier);
sessionStorage.setItem('oauth_state', state);
// Build authorization URL
const authUrl = new URL('https://auth.transitspotter.com/external/authorize');
authUrl.searchParams.append('response_type', 'code');
authUrl.searchParams.append('client_id', 'your-domain.com');
authUrl.searchParams.append('redirect_uri', 'https://your-domain.com/oauth/callback');
authUrl.searchParams.append('code_challenge', codeChallenge);
authUrl.searchParams.append('code_challenge_method', 'S256');
authUrl.searchParams.append('state', state);
// Redirect to authorization endpoint
window.location.href = authUrl.toString();
}
// 3. Handle the callback
async function handleOAuthCallback() {
// Get URL parameters
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
const state = urlParams.get('state');
const error = urlParams.get('error');
// Handle errors
if (error) {
console.error('OAuth error:', error, urlParams.get('error_description'));
return;
}
// Verify state to prevent CSRF attacks
const storedState = sessionStorage.getItem('oauth_state');
if (!state || state !== storedState) {
console.error('Invalid state parameter');
return;
}
// Get the code verifier
const codeVerifier = sessionStorage.getItem('code_verifier');
if (!codeVerifier) {
console.error('Code verifier not found');
return;
}
// Exchange code for tokens
try {
const response = await fetch('https://auth.transitspotter.com/external/token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Origin': window.location.origin
},
body: JSON.stringify({
grant_type: 'authorization_code',
code: code,
client_id: 'your-domain.com',
redirect_uri: 'https://your-domain.com/oauth/callback',
code_verifier: codeVerifier
})
});
const tokenData = await response.json();
if (tokenData.error) {
console.error('Token error:', tokenData.error, tokenData.error_description);
return;
}
// Store tokens securely
localStorage.setItem('access_token', tokenData.access_token);
localStorage.setItem('refresh_token', tokenData.refresh_token);
localStorage.setItem('token_expiry', Date.now() + (tokenData.expires_in * 1000));
// Clean up session storage
sessionStorage.removeItem('code_verifier');
sessionStorage.removeItem('oauth_state');
// Redirect to protected area or show success
console.log('Authentication successful!');
} catch (error) {
console.error('Token exchange error:', error);
}
}
// 4. Use the access token for API calls
async function fetchUserInfo() {
const accessToken = localStorage.getItem('access_token');
const response = await fetch('https://auth.transitspotter.com/external/userinfo', {
method: 'GET',
headers: {
'Authorization': 'Bearer ' + accessToken,
'Origin': window.location.origin
}
});
return await response.json();
}
// 5. Refresh the token when it expires
async function refreshToken() {
const refreshToken = localStorage.getItem('refresh_token');
try {
const response = await fetch('https://auth.transitspotter.com/external/token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Origin': window.location.origin
},
body: JSON.stringify({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: 'your-domain.com'
})
});
const tokenData = await response.json();
if (tokenData.error) {
console.error('Refresh error:', tokenData.error, tokenData.error_description);
// Handle failed refresh (e.g., redirect to login)
return false;
}
// Update stored tokens
localStorage.setItem('access_token', tokenData.access_token);
localStorage.setItem('refresh_token', tokenData.refresh_token);
localStorage.setItem('token_expiry', Date.now() + (tokenData.expires_in * 1000));
return true;
} catch (error) {
console.error('Token refresh error:', error);
return false;
}
}
// 6. Check if token needs refreshing before API calls
async function ensureValidToken() {
const expiryTime = parseInt(localStorage.getItem('token_expiry'), 10);
// Refresh if token is expired or will expire in the next 5 minutes
if (!expiryTime || Date.now() > expiryTime - 300000) {
return await refreshToken();
}
return true;
}
// 7. Logout - revoke tokens
async function logout() {
const refreshToken = localStorage.getItem('refresh_token');
try {
// Revoke the refresh token
await fetch('https://auth.transitspotter.com/external/revoke', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Origin': window.location.origin
},
body: JSON.stringify({
token: refreshToken,
token_type_hint: 'refresh_token'
})
});
// Clear local storage
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
localStorage.removeItem('token_expiry');
// Redirect to login page
window.location.href = '/login';
} catch (error) {
console.error('Logout error:', error);
}
}
/**
* OAuth 2.1 with PKCE Client for transitspotter
*/
// 1. Start the OAuth flow
function startOAuthFlow() {
// Generate code verifier (random string)
$codeVerifier = bin2hex(random_bytes(32));
// Generate code challenge (base64url encoded SHA-256 hash)
$codeChallenge = rtrim(strtr(base64_encode(hash('sha256', $codeVerifier, true)), '+/', '-_'), '=');
// Generate state for CSRF protection
$state = bin2hex(random_bytes(16));
// Store these in the session
$_SESSION['code_verifier'] = $codeVerifier;
$_SESSION['oauth_state'] = $state;
// Build authorization URL
$params = [
'response_type' => 'code',
'client_id' => 'your-domain.com',
'redirect_uri' => 'https://your-domain.com/oauth/callback.php',
'code_challenge' => $codeChallenge,
'code_challenge_method' => 'S256',
'state' => $state
];
$authUrl = 'https://auth.transitspotter.com/external/authorize?' . http_build_query($params);
// Redirect the user
header('Location: ' . $authUrl);
exit;
}
// 2. Handle the callback
function handleOAuthCallback() {
// Check for errors
if (isset($_GET['error'])) {
die('OAuth Error: ' . $_GET['error'] . ' - ' . ($_GET['error_description'] ?? 'No description'));
}
// Get the authorization code
$code = $_GET['code'] ?? null;
$state = $_GET['state'] ?? null;
if (!$code) {
die('No authorization code received');
}
// Verify state to prevent CSRF
if (!$state || $state !== $_SESSION['oauth_state']) {
die('Invalid state parameter');
}
// Get the code verifier from session
$codeVerifier = $_SESSION['code_verifier'] ?? null;
if (!$codeVerifier) {
die('Code verifier not found in session');
}
// Exchange code for tokens
$tokenData = exchangeCodeForTokens($code, $codeVerifier);
// Store tokens in session (or more securely in database)
$_SESSION['access_token'] = $tokenData['access_token'];
$_SESSION['refresh_token'] = $tokenData['refresh_token'];
$_SESSION['token_expiry'] = time() + $tokenData['expires_in'];
// Clear PKCE and state data
unset($_SESSION['code_verifier']);
unset($_SESSION['oauth_state']);
// Redirect to protected area
header('Location: /dashboard.php');
exit;
}
// 3. Exchange code for tokens
function exchangeCodeForTokens($code, $codeVerifier) {
// Prepare the token request
$tokenUrl = 'https://auth.transitspotter.com/external/token';
$tokenParams = [
'grant_type' => 'authorization_code',
'code' => $code,
'client_id' => 'your-domain.com',
'redirect_uri' => 'https://your-domain.com/oauth/callback.php',
'code_verifier' => $codeVerifier
];
// Create cURL request
$ch = curl_init($tokenUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($tokenParams));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
'Origin: https://your-domain.com'
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status !== 200) {
die('Token request failed with status ' . $status . ': ' . $response);
}
$tokenData = json_decode($response, true);
if (isset($tokenData['error'])) {
die('Token error: ' . $tokenData['error'] . ' - ' . ($tokenData['error_description'] ?? 'No description'));
}
return $tokenData;
}
// 4. Refresh tokens when they expire
function refreshTokens() {
$refreshToken = $_SESSION['refresh_token'] ?? null;
if (!$refreshToken) {
return false;
}
$tokenUrl = 'https://auth.transitspotter.com/external/token';
$tokenParams = [
'grant_type' => 'refresh_token',
'refresh_token' => $refreshToken,
'client_id' => 'your-domain.com'
];
// Create cURL request
$ch = curl_init($tokenUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($tokenParams));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
'Origin: https://your-domain.com'
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status !== 200) {
return false;
}
$tokenData = json_decode($response, true);
if (isset($tokenData['error'])) {
return false;
}
// Update stored tokens
$_SESSION['access_token'] = $tokenData['access_token'];
$_SESSION['refresh_token'] = $tokenData['refresh_token'];
$_SESSION['token_expiry'] = time() + $tokenData['expires_in'];
return true;
}
// 5. Ensure we have a valid token before API calls
function ensureValidToken() {
if (!isset($_SESSION['token_expiry']) || time() > $_SESSION['token_expiry'] - 300) {
// Token is expired or will expire in the next 5 minutes
if (!refreshTokens()) {
// Refresh failed, redirect to login
header('Location: /login.php');
exit;
}
}
return $_SESSION['access_token'];
}
// 6. Make authenticated API request
function makeAuthenticatedRequest($url) {
$accessToken = ensureValidToken();
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Authorization: Bearer ' . $accessToken,
'Origin: https://your-domain.com'
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status === 401) {
// Try refreshing the token once
if (refreshTokens()) {
return makeAuthenticatedRequest($url); // Retry with new token
} else {
// Refresh failed, redirect to login
header('Location: /login.php');
exit;
}
}
return json_decode($response, true);
}
// 7. Logout - revoke tokens
function logout() {
$refreshToken = $_SESSION['refresh_token'] ?? null;
if ($refreshToken) {
$revokeUrl = 'https://auth.transitspotter.com/external/revoke';
$revokeParams = [
'token' => $refreshToken,
'token_type_hint' => 'refresh_token'
];
// Create cURL request
$ch = curl_init($revokeUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($revokeParams));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
'Origin: https://your-domain.com'
]);
curl_exec($ch);
curl_close($ch);
}
// Clear session data
unset($_SESSION['access_token']);
unset($_SESSION['refresh_token']);
unset($_SESSION['token_expiry']);
// Redirect to login page
header('Location: /login.php');
exit;
}
The API implements rate limiting to prevent abuse. If you exceed the rate limits, you'll receive a 429 Too Many Requests response with a Retry-After header.
Common rate limits:
For questions or to request domain approval, please contact the transitspotter team at support@transitspotter.com.