Skip to main content
The Zenoti integration defines two custom error classes for API and authentication failures. All errors are thrown as typed exceptions with detailed context for debugging.

Error Classes

ZenotiApiError

Thrown for HTTP errors (4xx, 5xx) after retry exhaustion.
export class ZenotiApiError extends Error {
  status: number
  response: ZenotiErrorResponse | null

  constructor(
    status: number,
    message: string,
    response?: ZenotiErrorResponse | null,
  )
}
status
number
HTTP status code (e.g., 400, 404, 500)
message
string
Error message (extracted from Zenoti response or generic)
response
ZenotiErrorResponse | null
Full Zenoti error response body, if available
Zenoti Error Response Format:
export interface ZenotiErrorResponse {
  errors: ZenotiError[]
  status: number
}

export interface ZenotiError {
  code: string
  message: string
  field?: string // Field name if validation error
}

ZenotiAuthError

Thrown when token generation fails due to missing or invalid credentials.
export class ZenotiAuthError extends Error {
  constructor(message: string)
}
message
string
Authentication error description

Common Error Scenarios

Missing Credentials

Thrown when applicationId, secretKey, or accountName are missing during token generation.
ZenotiAuthError: Missing applicationId, secretKey, or accountNamecannot generate token.
Source: client.ts:58-62

Invalid Credentials

Thrown when Zenoti rejects the token request (401 Unauthorized).
ZenotiAuthError: Token request failed (401)

Rate Limiting (429)

Automatically retried with exponential backoff. If all retries are exhausted:
ZenotiApiError: Request failed (429)
// status: 429
// response: { errors: [{ code: 'RATE_LIMIT_EXCEEDED', message: '...' }] }
Retry strategy: 3 attempts with delays of 1s, 2s, 4s (or uses Retry-After header if present). Source: client.ts:141-179

Resource Not Found (404)

ZenotiApiError: Request failed (404)
// status: 404

Validation Errors (400)

Zenoti returns validation errors with field-level details:
{
  "errors": [
    {
      "code": "INVALID_DATE_RANGE",
      "message": "start_date must be before end_date",
      "field": "start_date"
    }
  ],
  "status": 400
}

Server Errors (5xx)

Transient server errors are automatically retried. If all retries fail:
ZenotiApiError: Request failed (500)

Handling Patterns

Try-Catch with Type Guards

import { 
  zenotiRequest, 
  ZenotiApiError, 
  ZenotiAuthError 
} from '@/integrations/zenoti'

try {
  const data = await zenotiRequest('/v1/appointments', { params })
} catch (error) {
  if (error instanceof ZenotiAuthError) {
    // Authentication failures
    console.error('Auth failed:', error.message)
    clearAccessToken()
    redirectToLogin()
  } else if (error instanceof ZenotiApiError) {
    // API errors
    console.error(`API error (${error.status}):`, error.message)
    
    switch (error.status) {
      case 400:
        handleValidationError(error.response)
        break
      case 404:
        handleNotFound()
        break
      case 429:
        handleRateLimit()
        break
      default:
        handleGenericError(error)
    }
  } else {
    // Unknown error (network, etc.)
    console.error('Unexpected error:', error)
    throw error
  }
}

React Query Error Handling

Hooks automatically handle errors via React Query:
import { useAppointments } from '@/integrations/zenoti'
import { ZenotiApiError, ZenotiAuthError } from '@/integrations/zenoti'

function AppointmentList() {
  const { data, error, isLoading, isError } = useAppointments()

  if (isLoading) return <Spinner />
  
  if (isError) {
    if (error instanceof ZenotiAuthError) {
      return (
        <ErrorState
          title="Authentication Failed"
          message="Please check your Zenoti credentials"
          action={<Button onClick={goToSettings}>Configure</Button>}
        />
      )
    }
    
    if (error instanceof ZenotiApiError) {
      return (
        <ErrorState
          title="Failed to Load Appointments"
          message={error.message}
          action={<Button onClick={() => refetch()}>Retry</Button>}
        />
      )
    }
    
    return <ErrorState title="Unknown Error" message={error.message} />
  }

  return <AppointmentGrid appointments={data} />
}

Global Error Boundary

Catch unhandled Zenoti errors at the app level:
import { ErrorBoundary } from 'react-error-boundary'
import { ZenotiApiError, ZenotiAuthError } from '@/integrations/zenoti'

function ErrorFallback({ error, resetErrorBoundary }) {
  if (error instanceof ZenotiAuthError) {
    return (
      <div>
        <h1>Authentication Error</h1>
        <p>{error.message}</p>
        <button onClick={() => {
          clearAccessToken()
          resetErrorBoundary()
        }}>
          Reconfigure
        </button>
      </div>
    )
  }

  if (error instanceof ZenotiApiError) {
    return (
      <div>
        <h1>API Error ({error.status})</h1>
        <p>{error.message}</p>
        <button onClick={resetErrorBoundary}>Try Again</button>
      </div>
    )
  }

  return <div>Unexpected error: {error.message}</div>
}

function App() {
  return (
    <ErrorBoundary FallbackComponent={ErrorFallback}>
      <Dashboard />
    </ErrorBoundary>
  )
}

Retry Configuration

The HTTP client retries transient errors automatically:
client.ts:141-179
const MAX_RETRIES = 3
const RETRY_DELAYS = [1000, 2000, 4000] // ms

for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
  const res = await fetch(url, { method, headers, body })

  if (res.ok) return await res.json()

  // Retry on 429 (rate limit) or 5xx (server errors)
  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
  }

  // Non-retryable or exhausted retries → throw
  throw new ZenotiApiError(res.status, errorMessage, errorBody)
}
Retryable errors:
  • 429 (Too Many Requests)
  • 500–599 (Server Errors)
Non-retryable errors:
  • 400 (Bad Request)
  • 401 (Unauthorized)
  • 403 (Forbidden)
  • 404 (Not Found)
  • Other 4xx errors

Debugging Tips

Inspect Full Error Response

try {
  const data = await zenotiRequest('/v1/appointments', { params })
} catch (error) {
  if (error instanceof ZenotiApiError) {
    console.error('Status:', error.status)
    console.error('Message:', error.message)
    console.error('Full response:', JSON.stringify(error.response, null, 2))
  }
}

Log Request Details

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

const path = '/v1/appointments'
const options = {
  params: {
    center_id: 'abc123',
    start_date: '2026-03-01',
    end_date: '2026-03-31',
  },
}

console.log('Zenoti request:', { path, options })

try {
  const data = await zenotiRequest(path, options)
  console.log('Response:', data)
} catch (error) {
  console.error('Request failed:', { path, options, error })
}

Check Connection State

import { useZenotiStore } from '@/stores/useZenotiStore'
import { useZenotiConnectionTest } from '@/integrations/zenoti'

function DebugPanel() {
  const isConnected = useZenotiStore(s => s.isConnected)
  const { refetch } = useZenotiConnectionTest()

  const testConnection = async () => {
    const { isSuccess, data, error } = await refetch()
    console.log('Connection test:', { isSuccess, data, error })
  }

  return (
    <div>
      <p>Connected: {isConnected ? 'Yes' : 'No'}</p>
      <button onClick={testConnection}>Test Connection</button>
    </div>
  )
}

Common Issues

”Missing applicationId, secretKey, or accountName”

Cause: Environment variables not set Solution:
# .env
VITE_ZENOTI_APP_ID=your_app_id
VITE_ZENOTI_SECRET_KEY=your_secret_key
VITE_ZENOTI_ACCOUNT_NAME=your_account

“Token request failed (401)”

Cause: Invalid Application ID or Secret Key Solution: Verify credentials in Zenoti Admin > Setup > Apps

”Request failed (429)” (after retries)

Cause: Exceeded Zenoti API rate limits Solution:
  • Reduce request frequency
  • Implement request batching
  • Contact Zenoti support to increase limits

”Request failed (404)”

Cause: Resource not found (invalid ID or deleted resource) Solution: Validate IDs before making requests

Network Errors

Cause: Network connectivity issues, firewall, etc. Solution:
try {
  const data = await zenotiRequest('/v1/centers')
} catch (error) {
  if (error instanceof TypeError && error.message.includes('fetch')) {
    console.error('Network error:', error.message)
    toast.error('Network error', {
      description: 'Please check your internet connection',
    })
  }
}

Error Response Examples

Validation Error

{
  "errors": [
    {
      "code": "INVALID_PARAMETER",
      "message": "center_id is required",
      "field": "center_id"
    }
  ],
  "status": 400
}

Authentication Error

{
  "errors": [
    {
      "code": "INVALID_CREDENTIALS",
      "message": "Invalid application_id or secret_key"
    }
  ],
  "status": 401
}

Rate Limit Error

{
  "errors": [
    {
      "code": "RATE_LIMIT_EXCEEDED",
      "message": "You have exceeded the rate limit. Please retry after 60 seconds."
    }
  ],
  "status": 429
}

Server Error

{
  "errors": [
    {
      "code": "INTERNAL_SERVER_ERROR",
      "message": "An unexpected error occurred. Please try again later."
    }
  ],
  "status": 500
}

HTTP Client

Request handling and retry logic

Authentication

Token generation and credential setup

React Hooks

Error handling in React Query hooks

Build docs developers (and LLMs) love