Building Apps with the Evercam OAuth API
Quick start: Register your app
Developer Portal
If you have a regular Evercam account, use the developer portal to register a new app:

Create an app by filling in the required fields: app name, redirect URL, and required scopes

The response includes client_id and client_secret. Save the secret — it's only shown once. you can regenerate a new client secrets later.

For public clients (SPAs, mobile apps — anything where you can't keep a secret), add "is_public": true. Public clients won't receive a client_secret and must use PKCE (see below).
Authorisation Code Flow (recommended for web apps)
This is the standard OAuth 2.0 flow. Use it for any app with a backend that can securely store a client_secret.
Step 1: Redirect the user to authorise
POST https://media.evercam.io/oauth/authorize
?client_id=ev_YOUR_CLIENT_ID
&redirect_uri=https://your-app.vercel.app/callback
&response_type=code
&scope=cameras:read camera:live_view
&state=random-csrf-tokenThe user sees a consent screen dash.evercam.io listing the permissions you requested. If they approve, they're redirected back:
https://your-app.vercel.app/callback?code=AUTH_CODE&state=random-csrf-tokenAlways verify state matches what you sent to prevent CSRF attacks.
Step 2: Exchange code for tokens
curl -X POST https://media.evercam.io/oauth/token \
-H "Content-Type: application/json" \
-d '{
"grant_type": "authorization_code",
"client_id": "ev_YOUR_CLIENT_ID",
"client_secret": "evs_YOUR_SECRET",
"code": "AUTH_CODE",
"redirect_uri": "https://your-app.vercel.app/callback"
}'Response:
{
"access_token": "eyJ...",
"refresh_token": "eyJ...",
"token_type": "Bearer",
"expires_in": 3600
}Step 3: Call the API
curl https://media.evercam.io/v2/cameras \
-H "Authorization: Bearer ACCESS_TOKEN"The token only has access to endpoints covered by the scopes you requested. Anything else returns 403 Forbidden.
Step 4: Refresh when expired
Access tokens expire after 1 hour. Use the refresh token to get a new pair:
curl -X POST https://media.evercam.io/oauth/token \
-H "Content-Type: application/json" \
-d '{
"grant_type": "refresh_token",
"client_id": "ev_YOUR_CLIENT_ID",
"client_secret": "evs_YOUR_SECRET",
"refresh_token": "REFRESH_TOKEN"
}'Refresh tokens are rotated — each refresh gives you a new refresh token and invalidates the old one. Refresh tokens are valid for 30 days.
Authorisation Code Flow with PKCE (for SPAs and mobile apps)
If your app runs entirely in the browser (React, Next.js frontend-only, etc.) or is a native mobile app, you can't safely store a client_secret. Use PKCE instead.
Register your app as a public client ("is_public": true).
Step 1: Generate PKCE challenge
// Generate a random code_verifier (43-128 chars, URL-safe)
function generateVerifier() {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return btoa(String.fromCharCode(...array))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
// Derive code_challenge from verifier using SHA-256
async function generateChallenge(verifier) {
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const digest = await crypto.subtle.digest('SHA-256', data);
return btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
const codeVerifier = generateVerifier();
const codeChallenge = await generateChallenge(codeVerifier);
// Store codeVerifier in sessionStorage — you'll need it in Step 2
sessionStorage.setItem('pkce_verifier', codeVerifier);Step 2: Redirect to authorise (with PKCE params)
POST https://media.evercam.io/oauth/authorize
?client_id=ev_YOUR_CLIENT_ID
&redirect_uri=https://your-app.vercel.app/callback
&response_type=code
&scope=cameras:read camera:live_view
&state=random-csrf-token
&code_challenge=CODE_CHALLENGE
&code_challenge_method=S256Step 3: Exchange code (with verifier, no secret)
curl -X POST https://media.evercam.io/oauth/token \
-H "Content-Type: application/json" \
-d '{
"grant_type": "authorization_code",
"client_id": "ev_YOUR_CLIENT_ID",
"code": "AUTH_CODE",
"redirect_uri": "https://your-app.vercel.app/callback",
"code_verifier": "THE_ORIGINAL_VERIFIER"
}'No client_secret needed. The server verifies the code_verifier matches the code_challenge from Step 2.
Device Authorisation Flow (for CLI tools and IoT)
Building a CLI tool, a Raspberry Pi integration, or anything without a browser? Use the Device Authorisation Grant (RFC 8628).
Step 1: Request a device code
curl -X POST https://media.evercam.io/oauth/device/authorize \
-H "Content-Type: application/json" \
-d '{
"client_id": "ev_YOUR_CLIENT_ID",
"scope": "cameras:read camera:live_view"
}'Response:
{
"device_code": "DEVICE_CODE",
"user_code": "BDFG-HJKL",
"verification_uri": "https://dash.evercam.io/oauth/device",
"verification_uri_complete": "https://dash.evercam.io/oauth/device?user_code=BDFG-HJKL",
"expires_in": 900,
"interval": 5
}Step 2: Show the user code
Display to the user:
To sign in, open https://dash.evercam.io/oauth/device
and enter code: BDFG-HJKLOr show them a QR code pointing to verification_uri_complete.
Step 3: Poll for approval
curl -X POST https://media.evercam.io/oauth/token \
-H "Content-Type: application/json" \
-d '{
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
"device_code": "DEVICE_CODE",
"client_id": "ev_YOUR_CLIENT_ID"
}'Possible responses while waiting:
"error": "authorization_pending"— user hasn't approved yet, keep polling"error": "slow_down"— you're polling too fast, increase interval by 5 seconds"error": "access_denied"— user denied the request"error": "expired_token"— the device code expired (15 min TTL)
On success, you get the same access_token + refresh_token response as the other flows.
Respect the polling interval. Start at the interval value from Step 1 (default 5 seconds) and back off if you get slow_down.
Available scopes
Request only the scopes your app actually needs.
User
Scope | Description |
|---|---|
| Read user profile |
| Modify user profile |
Projects
Scope | Description |
|---|---|
| List and view projects |
Cameras
Scope | Description |
|---|---|
| List and view cameras |
| Access live camera streams |
| View camera recordings |
| Control camera PTZ |
Project features
Scope | Description |
|---|---|
| Gate reporting |
| Project timeline |
| Media management |
| BIM integration |
| Drone integration |
| 360-degree view |
| Project planning |
| Smart search |
| PPE monitoring |
| Weather integration |
| Video wall |
| Automation rules |
| Third-party connectors |
| Comments system |
| Project settings |
| Widget management |
Token lifecycle
Token | Lifetime | Notes |
|---|---|---|
Authorization code | 10 minutes | Single use |
Access token | 1 hour | Use |
Refresh token | 30 days | Rotated on each use — save the new one |
Device code | 15 minutes | Single use, deleted after exchange |
Revoking tokens
curl -X POST https://media.evercam.io/oauth/revoke \
-H "Content-Type: application/json" \
-d '{
"token": "TOKEN_TO_REVOKE",
"client_id": "ev_YOUR_CLIENT_ID",
"client_secret": "evs_YOUR_SECRET"
}'Introspecting tokens
Check if a token is still valid and what scopes it has:
curl -X POST https://media.evercam.io/oauth/introspect \
-H "Content-Type: application/json" \
-d '{
"token": "TOKEN_TO_CHECK",
"client_id": "ev_YOUR_CLIENT_ID",
"client_secret": "evs_YOUR_SECRET"
}'End-to-end example: React app with PKCE
Here's a minimal example for a Next.js or React app deployed on Vercel:
// lib/oauth.ts
const CLIENT_ID = process.env.NEXT_PUBLIC_EVERCAM_CLIENT_ID!;
const REDIRECT_URI = process.env.NEXT_PUBLIC_REDIRECT_URI!;
const AUTH_URL = 'https://media.evercam.io/oauth/authorize';
const TOKEN_URL = 'https://media.evercam.io/oauth/token';
function generateRandomString(length: number): string {
const array = new Uint8Array(length);
crypto.getRandomValues(array);
return btoa(String.fromCharCode(...array))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
async function sha256(plain: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(plain);
const digest = await crypto.subtle.digest('SHA-256', data);
return btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
export async function startLogin(scopes: string[] = ['cameras:read', 'camera:live_view']) {
const state = generateRandomString(16);
const codeVerifier = generateRandomString(32);
const codeChallenge = await sha256(codeVerifier);
sessionStorage.setItem('oauth_state', state);
sessionStorage.setItem('pkce_verifier', codeVerifier);
const params = new URLSearchParams({
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
response_type: 'code',
scope: scopes.join(' '),
state,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
window.location.href = `${AUTH_URL}?${params}`;
}
export async function handleCallback(searchParams: URLSearchParams) {
const code = searchParams.get('code');
const state = searchParams.get('state');
const savedState = sessionStorage.getItem('oauth_state');
const codeVerifier = sessionStorage.getItem('pkce_verifier');
if (!code || state !== savedState || !codeVerifier) {
throw new Error('Invalid OAuth callback');
}
const response = await fetch(TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'authorization_code',
client_id: CLIENT_ID,
code,
redirect_uri: REDIRECT_URI,
code_verifier: codeVerifier,
}),
});
// Clean up
sessionStorage.removeItem('oauth_state');
sessionStorage.removeItem('pkce_verifier');
if (!response.ok) {
throw new Error('Token exchange failed');
}
return response.json(); // { access_token, refresh_token, expires_in }
}// app/callback/page.tsx
'use client';
import { useEffect } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import { handleCallback } from '@/lib/oauth';
export default function CallbackPage() {
const searchParams = useSearchParams();
const router = useRouter();
useEffect(() => {
handleCallback(searchParams)
.then(tokens => {
// Store tokens securely (httpOnly cookie via API route is ideal)
localStorage.setItem('evercam_access_token', tokens.access_token);
localStorage.setItem('evercam_refresh_token', tokens.refresh_token);
router.push('/');
})
.catch(err => {
console.error('OAuth error:', err);
router.push('/login?error=auth_failed');
});
}, [searchParams, router]);
return <p>Signing in...</p>;
}Common mistakes
Mistake | What to do instead |
|---|---|
Asking users to paste their JWT | Use any of the OAuth flows above |
Requesting all scopes "just in case" | Request only what you need, users see the scope list on consent |
Ignoring token expiry | Check |
Storing tokens in | Use |
Not using PKCE for public clients | Always use PKCE with |
Not validating | Always generate, store, and verify |
FAQs
Q: Can I use http://localhost as a redirect URI during development? Yes. Localhost redirect URIs are allowed over HTTP (per RFC 8252), and port matching is flexible — registering http://localhost:3000/callback will also work for other localhost ports.
Q: What happens if the user denies consent? They are redirected back to your redirect_uri with error=access_denied in the query string.
Q: Can I use OAuth for server-to-server (no user involved)? Not currently. All OAuth flows require a user to authorise. For server-to-server, use API keys (x-api-id + x-api-key headers).
Q: My app is a CLI tool, which flow should I use? Device Authorisation Flow. It shows the user a code to enter in their browser.
Q: How do I add more scopes later? Update your app’s scopes via PATCH /v2/developer/apps/:client_id. Existing tokens keep their original scopes — users will see the updated scope list on their next authorisation.
Need help?
API docs: https://media.evercam.io/redoc.html
Questions: Reach out to Evercam support (support@evercam.io)