Skip to main content
The Etienne Intelligence Platform supports two authentication methods for the Zenoti API: API Key (recommended for server-to-server) and Bearer Token (OAuth-style with employee credentials).

Authentication Methods

API Keys are long-lived credentials (valid ~1 year) ideal for server-to-server integrations. Advantages:
  • Simple setup with single environment variable
  • No token refresh logic required
  • Reduced latency (no OAuth handshake)
  • Lower rate limit consumption (no /v1/tokens calls)
Configuration:
# .env.local
VITE_ZENOTI_BASE_URL=https://api.zenoti.com
VITE_ZENOTI_API_KEY=your-api-key-here
Implementation:
// From: src/integrations/zenoti/client.ts:126
const authHeader: Record<string, string> = {}

if (config.apiKey) {
  // Prefer API Key for server-to-server
  authHeader['Authorization'] = config.apiKey
} else {
  // Fallback to Bearer Token
  const token = await getAccessToken(config)
  authHeader['Authorization'] = `bearer ${token}`
}
Where to Find Your API Key:
1

Open Zenoti Admin

Navigate to Zenoti Admin > Setup > Apps
2

Select Your App

Click on your backend application (e.g., “Etienne Intelligence Platform”)
3

Copy API Key

Under API Key section, click Show and copy the key
4

Add to Environment

Paste into VITE_ZENOTI_API_KEY in your .env.local file
API Keys are sensitive credentials. Never expose them in client-side code, commit them to version control, or share them in logs.

Bearer Token Authentication

Bearer tokens are short-lived (24 hours) and require an OAuth-style token exchange using Application ID and Secret Key. Advantages:
  • User-scoped access with employee credentials
  • Auto-rotation for enhanced security
  • Granular permission control per employee
Configuration:
# .env.local
VITE_ZENOTI_BASE_URL=https://api.zenoti.com
VITE_ZENOTI_APP_ID=your-application-id
VITE_ZENOTI_SECRET_KEY=your-secret-key
VITE_ZENOTI_ACCOUNT_NAME=your-organization-name
Token Generation:
// From: src/integrations/zenoti/client.ts:53
export async function getAccessToken(config: ZenotiConfig): Promise<string> {
  // Return cached token if still valid (< 90% of lifetime)
  if (isTokenValid()) return cachedToken!
  
  if (!config.applicationId || !config.secretKey || !config.accountName) {
    throw new ZenotiAuthError(
      'Missing applicationId, secretKey, or accountName — cannot generate token.'
    )
  }
  
  const body: ZenotiTokenRequest = {
    account_name: config.accountName,
    application_id: config.applicationId,
    secret_key: config.secretKey,
  }
  
  const res = await fetch(`${config.baseUrl}/v1/tokens`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  })
  
  if (!res.ok) {
    const err = await res.json().catch(() => null) as ZenotiErrorResponse | null
    throw new ZenotiAuthError(
      err?.errors?.[0]?.message ?? `Token request failed (${res.status})`
    )
  }
  
  const data = await res.json() as ZenotiTokenResponse
  cachedToken = data.access_token
  
  // Refresh at 90% of expiry window (default 24h = 86,400s)
  tokenExpiresAt = Date.now() + (data.expires_in ?? 86_400) * 900
  
  return cachedToken
}
Token Caching: The client caches tokens in memory and auto-refreshes at 90% of their lifetime:
// From: src/integrations/zenoti/client.ts:38
let cachedToken: string | null = null
let tokenExpiresAt = 0

function isTokenValid(): boolean {
  return cachedToken !== null && Date.now() < tokenExpiresAt
}
Clear Cached Token:
// From: src/integrations/zenoti/client.ts:91
import { clearAccessToken } from '@/integrations/zenoti'

// Call when user disconnects or logs out
clearAccessToken()

Token Request/Response Types

The integration uses TypeScript for type-safe authentication:
// From: src/integrations/zenoti/types.ts:8
export interface ZenotiTokenRequest {
  account_name: string
  application_id: string
  secret_key: string
  /** Employee username — required for bearer-token auth */
  user_name?: string
  /** Employee password — required for bearer-token auth */
  password?: string
}

export interface ZenotiTokenResponse {
  access_token: string
  token_type: 'bearer'
  expires_in: number
  /** ISO timestamp of when the token was issued */
  issued_at?: string
}

Security Best Practices

Follow these security guidelines to protect your Zenoti integration:

Environment Variable Security

DO:
  • Store credentials in .env.local (gitignored by default)
  • Use different credentials for development, staging, and production
  • Rotate API keys every 6-12 months
  • Use secret management services (AWS Secrets Manager, Vault) in production
DON’T:
  • Commit .env.local to version control
  • Expose credentials in client-side code
  • Share credentials via email, Slack, or other insecure channels
  • Log API keys or tokens in application logs

API Key Rotation

1

Generate New Key

In Zenoti Admin > Setup > Apps, generate a new API key for your application
2

Update Production

Update VITE_ZENOTI_API_KEY in your production environment variables
3

Deploy & Test

Deploy the updated config and verify the connection works
4

Revoke Old Key

After confirming the new key works, revoke the old key in Zenoti Admin

Rate Limiting

Zenoti enforces rate limits on API requests. The client includes automatic retry logic:
// From: src/integrations/zenoti/client.ts:141
const MAX_RETRIES = 3
const RETRY_DELAYS = [1000, 2000, 4000] // Exponential backoff in ms

for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
  const res = await fetch(url.toString(), { method, headers, body })
  
  if (res.ok) return await res.json()
  
  const isRetryable = res.status === 429 || (res.status >= 500 && res.status < 600)
  
  if (isRetryable && attempt < MAX_RETRIES) {
    // Use Retry-After header if present, else exponential backoff
    const retryAfter = res.headers.get('Retry-After')
    const delay = retryAfter
      ? parseInt(retryAfter, 10) * 1000
      : RETRY_DELAYS[attempt]
    await sleep(delay)
    continue
  }
  
  throw new ZenotiApiError(res.status, errorMessage)
}
Rate Limit Best Practices:
  • Use React Query caching to reduce redundant requests
  • Batch requests when fetching data for multiple centers
  • Increase staleTime for rarely-changing data (centers, services)
  • Implement request queuing for bulk operations

Error Handling

The integration provides custom error classes for graceful degradation:
// From: src/integrations/zenoti/client.ts:184
export class ZenotiApiError extends Error {
  status: number
  response: ZenotiErrorResponse | null
  
  constructor(
    status: number,
    message: string,
    response?: ZenotiErrorResponse | null
  ) {
    super(message)
    this.name = 'ZenotiApiError'
    this.status = status
    this.response = response ?? null
  }
}

export class ZenotiAuthError extends Error {
  constructor(message: string) {
    super(message)
    this.name = 'ZenotiAuthError'
  }
}
Usage Example:
import { listCenters, ZenotiAuthError, ZenotiApiError } from '@/integrations/zenoti'

try {
  const centers = await listCenters()
  console.log(`Found ${centers.length} centers`)
} catch (error) {
  if (error instanceof ZenotiAuthError) {
    // Authentication failed — prompt user to check credentials
    showAlert('Invalid Zenoti credentials. Please check your API key.')
  } else if (error instanceof ZenotiApiError) {
    if (error.status === 429) {
      // Rate limit exceeded — client already retried, notify user
      showAlert('Zenoti rate limit exceeded. Please try again in a few minutes.')
    } else if (error.status >= 500) {
      // Zenoti server error — log and use fallback data
      console.error('Zenoti server error:', error.message)
      useFallbackData()
    }
  }
}

Configuration Reference

Complete environment variable reference:
// From: src/integrations/zenoti/client.ts:13
export interface ZenotiConfig {
  /** Base URL — differs per data center (US, EU, AU, etc.) */
  baseUrl: string
  /** API Key (long-lived, valid ~1 year) — used for server-to-server calls */
  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
}
VariableRequiredDescriptionExample
VITE_ZENOTI_BASE_URLYesZenoti API base URL (varies by region)https://api.zenoti.com
VITE_ZENOTI_API_KEYNo*Long-lived API key for server-to-server authak_live_...
VITE_ZENOTI_APP_IDNo*Application ID from Zenoti Adminapp_123456
VITE_ZENOTI_SECRET_KEYNo*Secret key for bearer token generationsk_live_...
VITE_ZENOTI_ACCOUNT_NAMENo*Organization name in Zenotispa-wellness-co
*You must provide either VITE_ZENOTI_API_KEY OR the trio of VITE_ZENOTI_APP_ID, VITE_ZENOTI_SECRET_KEY, and VITE_ZENOTI_ACCOUNT_NAME.

Testing Authentication

Use the connection test hook to verify your credentials:
// From: src/integrations/zenoti/hooks.ts:186
import { useZenotiConnectionTest } from '@/integrations/zenoti'

function ConnectionStatus() {
  const { refetch, data, error, isLoading } = useZenotiConnectionTest()
  
  const testConnection = async () => {
    const result = await refetch()
    
    if (result.isSuccess) {
      console.log('✓ Connection successful')
      console.log(`Found ${result.data.centerCount} centers`)
      console.log('Centers:', result.data.centerNames.join(', '))
    } else if (result.error instanceof ZenotiAuthError) {
      console.error('✗ Authentication failed:', result.error.message)
    } else {
      console.error('✗ Connection failed:', result.error)
    }
  }
  
  return (
    <div>
      <button onClick={testConnection} disabled={isLoading}>
        {isLoading ? 'Testing...' : 'Test Connection'}
      </button>
      {data && <p>Connected to {data.centerCount} centers</p>}
      {error && <p className="error">{error.message}</p>}
    </div>
  )
}

Webhook Authentication (Future)

The integration includes type definitions for webhook events:
// From: src/integrations/zenoti/types.ts:347
export interface ZenotiWebhookEvent {
  event_type:
    | 'appointment.created'
    | 'appointment.updated'
    | 'appointment.cancelled'
    | 'appointment.noshow'
    | 'appointment.completed'
    | 'guest.created'
    | 'guest.updated'
    | 'invoice.created'
    | 'invoice.closed'
  timestamp: string
  center_id: string
  data: Record<string, unknown>
}
Webhook authentication requires validating Zenoti’s signature header. Refer to Zenoti’s webhook documentation for signature validation details.

Next Steps

Integration Overview

Learn about available endpoints and data sync capabilities

Zenoti Setup

Complete step-by-step setup guide

Build docs developers (and LLMs) love