Skip to main content
The Zenoti integration supports two authentication methods: long-lived API keys (recommended) and dynamically-generated bearer tokens.

Authentication Methods

Long-lived API key valid for approximately 1 year. Simplest method for server-to-server integration.
1

Generate API Key

In Zenoti Admin, navigate to Setup > Developer Portal > API Keys and create a new key.
2

Set Environment Variable

VITE_ZENOTI_API_KEY=your_api_key_here
3

Use in Requests

The client automatically uses the API key if present — no token generation needed.

Bearer Token

Dynamically generated from Application ID + Secret Key. Token is valid for 24 hours and cached automatically.
1

Create Zenoti Application

In Zenoti Admin, go to Setup > Apps and create a new application. Save the Application ID and Secret Key.
2

Set Environment Variables

VITE_ZENOTI_APP_ID=your_app_id
VITE_ZENOTI_SECRET_KEY=your_secret_key
VITE_ZENOTI_ACCOUNT_NAME=your_org_name
VITE_ZENOTI_BASE_URL=https://api.zenoti.com
3

Generate Token

Call getAccessToken() — the client caches the token and refreshes at 90% of its lifetime.

Token Generation

getAccessToken()

Generates and caches a bearer access token.
export async function getAccessToken(
  config: ZenotiConfig,
): Promise<string>
config
ZenotiConfig
required
Configuration object containing:
  • applicationId — Application ID from Zenoti
  • secretKey — Secret key from Zenoti
  • accountName — Organization name
  • baseUrl — API base URL (e.g., https://api.zenoti.com)
return
string
Bearer access token (cached for 24 hours)
Throws: ZenotiAuthError if required credentials are missing or token request fails

Example

import { zenotiRequest } from '@/integrations/zenoti'

// Token is automatically generated and cached
const centers = await zenotiRequest('/v1/centers')

Token Request Format

The token endpoint (POST /v1/tokens) expects:
export interface ZenotiTokenRequest {
  account_name: string
  application_id: string
  secret_key: string
  /** Employee username — required for user-level auth */
  user_name?: string
  /** Employee password — required for user-level auth */
  password?: string
}
Response:
export interface ZenotiTokenResponse {
  access_token: string
  token_type: 'bearer'
  expires_in: number // seconds (default 86,400 = 24 hours)
  issued_at?: string // ISO timestamp
}

Token Caching Strategy

Tokens are cached in memory and automatically refreshed:
client.ts:38-87
let cachedToken: string | null = null
let tokenExpiresAt = 0

function isTokenValid(): boolean {
  return cachedToken !== null && Date.now() < tokenExpiresAt
}

export async function getAccessToken(
  config: ZenotiConfig,
): Promise<string> {
  // Return cached token if still valid
  if (isTokenValid()) return cachedToken!

  // Generate new token
  const res = await fetch(`${config.baseUrl}/v1/tokens`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      account_name: config.accountName,
      application_id: config.applicationId,
      secret_key: config.secretKey,
    }),
  })

  const data = await res.json()
  cachedToken = data.access_token
  
  // Refresh at 90% of lifetime (24h * 900ms = 21.6h)
  tokenExpiresAt = Date.now() + (data.expires_in ?? 86_400) * 900
  
  return cachedToken
}
Key details:
  • Tokens expire after 24 hours (expires_in: 86400 seconds)
  • Client refreshes at 90% of lifetime (21.6 hours)
  • Cache is in-memory (cleared on app restart)
  • Thread-safe for concurrent requests (single cached token)

Clearing Token Cache

clearAccessToken()

Manually clear the cached token (e.g., on disconnect or credential change).
export function clearAccessToken(): void
import { clearAccessToken } from '@/integrations/zenoti'

function disconnectZenoti() {
  clearAccessToken()
  // Update connection state
  zenotiStore.setState({ isConnected: false })
}

Configuration Object

ZenotiConfig

export interface ZenotiConfig {
  /** Base URL — differs per data center (US, EU, AU) */
  baseUrl: string
  /** API Key — long-lived, valid ~1 year */
  apiKey?: string
  /** Application ID from Zenoti Admin > Setup > Apps */
  applicationId?: string
  /** Secret key generated alongside the Application ID */
  secretKey?: string
  /** Account / organization name in Zenoti */
  accountName?: string
}

getZenotiConfig()

Reads configuration from environment variables.
export function getZenotiConfig(): ZenotiConfig
return
ZenotiConfig
Configuration object with values from import.meta.env.VITE_ZENOTI_*
Environment variable mapping:
client.ts:26-36
export function getZenotiConfig(): ZenotiConfig {
  return {
    baseUrl: import.meta.env.VITE_ZENOTI_BASE_URL ?? 'https://api.zenoti.com',
    apiKey: import.meta.env.VITE_ZENOTI_API_KEY ?? undefined,
    applicationId: import.meta.env.VITE_ZENOTI_APP_ID ?? undefined,
    secretKey: import.meta.env.VITE_ZENOTI_SECRET_KEY ?? undefined,
    accountName: import.meta.env.VITE_ZENOTI_ACCOUNT_NAME ?? undefined,
  }
}

Regional Data Centers

Zenoti operates separate API endpoints per region:
RegionBase URLEnvironment Variable
United Stateshttps://api.zenoti.comVITE_ZENOTI_BASE_URL=https://api.zenoti.com
Europehttps://api-eu.zenoti.comVITE_ZENOTI_BASE_URL=https://api-eu.zenoti.com
Australiahttps://api-au.zenoti.comVITE_ZENOTI_BASE_URL=https://api-au.zenoti.com
Ensure your baseUrl matches your Zenoti account’s region. Requests to the wrong data center will fail with authentication errors.

Authentication Flow

The client uses a two-tier authentication strategy: Implementation (client.ts:125-132):
const authHeader: Record<string, string> = {}
if (config.apiKey) {
  // Prefer API key
  authHeader['Authorization'] = config.apiKey
} else {
  // Fallback to bearer token
  const token = await getAccessToken(config)
  authHeader['Authorization'] = `bearer ${token}`
}

Error Handling

Authentication Errors

import { 
  getAccessToken, 
  ZenotiAuthError 
} from '@/integrations/zenoti'

try {
  const token = await getAccessToken(config)
} catch (error) {
  if (error instanceof ZenotiAuthError) {
    // Missing credentials or invalid credentials
    console.error('Auth error:', error.message)
    // Examples:
    // "Missing applicationId, secretKey, or accountName"
    // "Token request failed (401)"
    // "Invalid secret key"
  }
}

Missing Credentials

client.ts:58-62
if (!config.applicationId || !config.secretKey || !config.accountName) {
  throw new ZenotiAuthError(
    'Missing applicationId, secretKey, or accountName — cannot generate token.',
  )
}

Token Request Failure

client.ts:76-80
if (!res.ok) {
  const err = await res.json().catch(() => null)
  throw new ZenotiAuthError(
    err?.errors?.[0]?.message ?? `Token request failed (${res.status})`,
  )
}

Security Best Practices

Never commit credentials to version control. Use environment variables or a secrets manager.
  1. Use API Keys for production — Simpler and more secure than managing application secrets
  2. Rotate keys regularly — Set calendar reminders to rotate API keys annually
  3. Use environment-specific credentials — Separate keys for dev, staging, production
  4. Restrict permissions — Configure Zenoti API keys with minimum required scopes
  5. Monitor usage — Track API calls in Zenoti’s developer portal for anomalies

Testing Connection

Use the connection test hook to verify credentials:
import { useZenotiConnectionTest } from '@/integrations/zenoti'

function SettingsPage() {
  const { refetch, data, error, isLoading } = useZenotiConnectionTest()

  const handleTestConnection = async () => {
    const result = await refetch()
    
    if (result.isSuccess) {
      console.log('Connected to', result.data.centerCount, 'centers')
      console.log('Centers:', result.data.centerNames)
    } else {
      console.error('Connection failed:', result.error)
    }
  }

  return (
    <button onClick={handleTestConnection} disabled={isLoading}>
      {isLoading ? 'Testing...' : 'Test Connection'}
    </button>
  )
}
See Hooks > useZenotiConnectionTest for full reference.

HTTP Client

Request handling and retry logic

Error Handling

ZenotiAuthError and ZenotiApiError reference

Build docs developers (and LLMs) love