Skip to main content
Glama
TallyApiClient.ts33.3 kB
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios'; import { z } from 'zod'; import { TokenManager, OAuth2Config, TokenStorage } from './TokenManager'; import { TallyApiError, AuthenticationError, RateLimitError, NetworkError, TimeoutError, createErrorFromResponse, } from '../models'; import { TallySubmissionsResponseSchema, TallyFormsResponseSchema, TallyWorkspacesResponseSchema, TallyFormSchema, TallySubmissionSchema, TallyWorkspaceSchema, validateTallyResponse, type TallySubmissionsResponse, type TallyFormsResponse, type TallyWorkspacesResponse, type TallyForm, type TallySubmission, type TallyWorkspace, } from '../models'; import { tallyApiMock } from './__mocks__/tally-api-mock'; /** * Retry configuration for rate limiting and error handling */ export interface RetryConfig { /** * Maximum number of retry attempts (defaults to 3) */ maxAttempts?: number; /** * Base delay in milliseconds for exponential backoff (defaults to 1000ms) */ baseDelayMs?: number; /** * Maximum delay in milliseconds to prevent excessively long waits (defaults to 30000ms) */ maxDelayMs?: number; /** * Exponential backoff base multiplier (defaults to 2) */ exponentialBase?: number; /** * Jitter factor to add randomness and prevent thundering herd (defaults to 0.1) */ jitterFactor?: number; /** * Enable circuit breaker pattern to prevent excessive retries during outages (defaults to true) */ enableCircuitBreaker?: boolean; /** * Number of consecutive failures before opening circuit breaker (defaults to 5) */ circuitBreakerThreshold?: number; /** * Time in milliseconds to wait before attempting to close circuit breaker (defaults to 60000ms) */ circuitBreakerTimeout?: number; } /** * Configuration options for the TallyApiClient */ export interface TallyApiClientConfig { /** * Base URL for the Tally API (defaults to https://api.tally.so) */ baseURL?: string; /** * Request timeout in milliseconds (defaults to 30 seconds) */ timeout?: number; /** * Additional default headers to include with requests */ defaultHeaders?: Record<string, string>; /** * OAuth 2.0 access token for authentication (for manual token management) */ accessToken?: string; /** * OAuth 2.0 configuration for automatic token management */ oauth2Config?: OAuth2Config; /** * Custom token storage implementation */ tokenStorage?: TokenStorage; /** * Enable request/response logging for debugging */ debug?: boolean; /** * Retry configuration for rate limiting and error handling */ retryConfig?: RetryConfig; } /** * Standard HTTP methods supported by the API client */ export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; /** * Generic API response wrapper */ export interface ApiResponse<T = any> { data: T; status: number; statusText: string; headers: Record<string, string>; } /** * TallyApiClient - API client for interacting with Tally.so API with OAuth 2.0 support * * This class provides a foundation for making HTTP requests to the Tally.so API * with proper configuration, OAuth 2.0 authentication, error handling, and type safety. */ /** * Circuit breaker states */ enum CircuitBreakerState { CLOSED = 'CLOSED', // Normal operation OPEN = 'OPEN', // Circuit is open, rejecting requests HALF_OPEN = 'HALF_OPEN' // Testing if circuit can be closed } export class TallyApiClient { private axiosInstance: AxiosInstance; private config: Omit<Required<TallyApiClientConfig>, 'oauth2Config' | 'tokenStorage'> & { oauth2Config?: OAuth2Config; tokenStorage?: TokenStorage; }; private tokenManager?: TokenManager; // Circuit breaker state private circuitBreakerState: CircuitBreakerState = CircuitBreakerState.CLOSED; private consecutiveFailures: number = 0; private lastFailureTime?: Date; /** * Create a new TallyApiClient instance * * @param config - Configuration options for the client */ constructor(config: TallyApiClientConfig = {}) { // Set default configuration values this.config = { baseURL: config.baseURL || 'https://api.tally.so', timeout: config.timeout || 30000, // 30 seconds defaultHeaders: { 'Content-Type': 'application/json', 'Accept': 'application/json', ...config.defaultHeaders, }, accessToken: config.accessToken || '', debug: config.debug || false, retryConfig: { maxAttempts: config.retryConfig?.maxAttempts ?? 3, baseDelayMs: config.retryConfig?.baseDelayMs ?? 1000, maxDelayMs: config.retryConfig?.maxDelayMs ?? 30000, exponentialBase: config.retryConfig?.exponentialBase ?? 2, jitterFactor: config.retryConfig?.jitterFactor ?? 0.1, enableCircuitBreaker: config.retryConfig?.enableCircuitBreaker ?? true, circuitBreakerThreshold: config.retryConfig?.circuitBreakerThreshold ?? 5, circuitBreakerTimeout: config.retryConfig?.circuitBreakerTimeout ?? 60000, }, }; // Add optional properties only if they are provided if (config.oauth2Config) { this.config.oauth2Config = config.oauth2Config; } if (config.tokenStorage) { this.config.tokenStorage = config.tokenStorage; } // Initialize TokenManager if OAuth2 config is provided if (this.config.oauth2Config) { this.tokenManager = new TokenManager(this.config.oauth2Config, this.config.tokenStorage); } // Initialize Axios instance with configuration this.axiosInstance = axios.create({ baseURL: this.config.baseURL, timeout: this.config.timeout, headers: this.config.defaultHeaders, responseType: 'json', }); // Add Authorization header if access token is provided if (this.config.accessToken) { this.axiosInstance.defaults.headers.common['Authorization'] = `Bearer ${this.config.accessToken}`; } // Setup request/response interceptors this.setupInterceptors(); } /** * Initialize OAuth 2.0 authentication flow * Returns the authorization URL that users should visit to grant permission * * @param state - Optional state parameter for CSRF protection * @returns Authorization URL */ public getAuthorizationUrl(state?: string): string { if (!this.tokenManager) { throw new Error('OAuth 2.0 configuration is required to get authorization URL'); } return this.tokenManager.getAuthorizationUrl(state); } /** * Exchange authorization code for access token * This should be called after the user grants permission and you receive the authorization code * * @param code - Authorization code from OAuth 2.0 callback * @returns Promise resolving to the token data */ public async exchangeCodeForToken(code: string) { if (!this.tokenManager) { throw new AuthenticationError('OAuth 2.0 configuration is required to exchange code for token'); } try { const token = await this.tokenManager.exchangeCodeForToken(code); // Update the axios instance with the new token this.axiosInstance.defaults.headers.common['Authorization'] = `Bearer ${token.access_token}`; return token; } catch (error) { throw new AuthenticationError('Failed to exchange code for token', undefined, undefined, undefined, error as Error); } } /** * Check if the client is authenticated * * @returns Promise resolving to true if authenticated, false otherwise */ public async isAuthenticated(): Promise<boolean> { if (this.config.accessToken) { return true; // Manual token is set } if (this.tokenManager) { return await this.tokenManager.hasValidToken(); } return false; } /** * Refresh the access token * * @returns Promise resolving to the new token data */ public async refreshToken() { if (!this.tokenManager) { throw new AuthenticationError('OAuth 2.0 configuration is required to refresh token'); } try { const token = await this.tokenManager.refreshToken(); // Update the axios instance with the new token this.axiosInstance.defaults.headers.common['Authorization'] = `Bearer ${token.access_token}`; return token; } catch (error) { throw new AuthenticationError('Failed to refresh token', undefined, undefined, undefined, error as Error); } } /** * Clear the stored authentication token */ public async clearAuthentication(): Promise<void> { if (this.tokenManager) { await this.tokenManager.clearToken(); } this.config.accessToken = ''; delete this.axiosInstance.defaults.headers.common['Authorization']; } /** * Update the access token for authentication (manual token management) * * @param token - New access token */ public setAccessToken(token: string): void { this.config.accessToken = token; if (token) { this.axiosInstance.defaults.headers.common['Authorization'] = `Bearer ${token}`; } else { delete this.axiosInstance.defaults.headers.common['Authorization']; } } /** * Get the current access token * * @returns The current access token or empty string if not set */ public getAccessToken(): string { return this.config.accessToken; } /** * Get the current token information (if using OAuth 2.0) */ public async getCurrentToken() { if (!this.tokenManager) { return null; } return await this.tokenManager.getCurrentToken(); } /** * Make a GET request to the specified endpoint * * @param url - The endpoint URL (relative to base URL) * @param config - Optional Axios request configuration * @returns Promise resolving to the API response */ public async get<T = any>(url: string, config?: AxiosRequestConfig): Promise<ApiResponse<T>> { return this.request<T>('GET', url, undefined, config); } /** * Make a POST request to the specified endpoint * * @param url - The endpoint URL (relative to base URL) * @param data - Request payload * @param config - Optional Axios request configuration * @returns Promise resolving to the API response */ public async post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<ApiResponse<T>> { return this.request<T>('POST', url, data, config); } /** * Make a PUT request to the specified endpoint * * @param url - The endpoint URL (relative to base URL) * @param data - Request payload * @param config - Optional Axios request configuration * @returns Promise resolving to the API response */ public async put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<ApiResponse<T>> { return this.request<T>('PUT', url, data, config); } /** * Make a DELETE request to the specified endpoint * * @param url - The endpoint URL (relative to base URL) * @param config - Optional Axios request configuration * @returns Promise resolving to the API response */ public async delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<ApiResponse<T>> { return this.request<T>('DELETE', url, undefined, config); } /** * Make a PATCH request to the specified endpoint * * @param url - The endpoint URL (relative to base URL) * @param data - Request payload * @param config - Optional Axios request configuration * @returns Promise resolving to the API response */ public async patch<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<ApiResponse<T>> { return this.request<T>('PATCH', url, data, config); } // =============================== // Type-Safe API Methods with Zod Validation // =============================== /** * Get form submissions with type-safe validation * * @param formId - The form ID to get submissions for * @param options - Query parameters for filtering and pagination * @returns Promise resolving to validated submissions response */ public async getSubmissions( formId: string, options: { page?: number; limit?: number; status?: 'all' | 'completed' | 'partial'; } = {} ): Promise<TallySubmissionsResponse> { if (this.isMockEnabled()) { const mockRes = await tallyApiMock.getSubmissions(formId, options); return mockRes.data; } const params = new URLSearchParams(); if (options.page) params.append('page', options.page.toString()); if (options.limit) params.append('limit', options.limit.toString()); if (options.status) params.append('status', options.status); const query = params.toString(); const url = `/forms/${formId}/submissions${query ? `?${query}` : ''}`; const response = await this.get(url); return validateTallyResponse(TallySubmissionsResponseSchema, response.data); } /** * Get a specific submission with type-safe validation * * @param formId - The form ID * @param submissionId - The submission ID * @returns Promise resolving to validated submission */ public async getSubmission(formId: string, submissionId: string): Promise<TallySubmission> { if (this.isMockEnabled()) { const mockRes = await tallyApiMock.getSubmission(formId, submissionId); return mockRes.data; } const url = `/forms/${formId}/submissions/${submissionId}`; const response = await this.get(url); return validateTallyResponse(TallySubmissionSchema, response.data); } /** * Get all forms with type-safe validation * * @param options - Query parameters for filtering and pagination * @returns Promise resolving to validated forms response */ public async getForms(options: { page?: number; limit?: number; workspaceId?: string; } = {}): Promise<TallyFormsResponse> { if (this.isMockEnabled()) { const mockRes = await tallyApiMock.getForms(options); return mockRes.data; } const params = new URLSearchParams(); if (options.page) params.append('page', options.page.toString()); if (options.limit) params.append('limit', options.limit.toString()); if (options.workspaceId) params.append('workspaceId', options.workspaceId); const query = params.toString(); const url = `/forms${query ? `?${query}` : ''}`; const response = await this.get(url); return validateTallyResponse(TallyFormsResponseSchema, response.data); } /** * Get a specific form with type-safe validation * * @param formId - The form ID * @returns Promise resolving to validated form */ public async getForm(formId: string): Promise<TallyForm> { if (this.isMockEnabled()) { const mockRes = await tallyApiMock.getForm(formId); return mockRes.data; } const url = `/forms/${formId}`; const response = await this.get(url); return validateTallyResponse(TallyFormSchema, response.data); } /** * Get workspaces with type-safe validation * * @param options - Query parameters for filtering and pagination * @returns Promise resolving to validated workspaces response */ public async getWorkspaces(options: { page?: number; limit?: number; } = {}): Promise<TallyWorkspacesResponse> { if (this.isMockEnabled()) { const mockRes = await tallyApiMock.getWorkspaces(options); return mockRes.data; } const params = new URLSearchParams(); if (options.page) params.append('page', options.page.toString()); if (options.limit) params.append('limit', options.limit.toString()); const query = params.toString(); const url = `/workspaces${query ? `?${query}` : ''}`; const response = await this.get(url); return validateTallyResponse(TallyWorkspacesResponseSchema, response.data); } /** * Get a specific workspace with type-safe validation * * @param workspaceId - The workspace ID * @returns Promise resolving to validated workspace */ public async getWorkspace(workspaceId: string): Promise<TallyWorkspace> { if (this.isMockEnabled()) { const mockRes = await tallyApiMock.getWorkspace(workspaceId); return mockRes.data; } const url = `/workspaces/${workspaceId}`; return this.requestWithValidation('GET', url, TallyWorkspaceSchema); } /** * Safely parse and validate any Tally API response using a provided schema * * @param schema - Zod schema to validate against * @param data - Raw response data to validate * @returns Safe parse result with success/error information */ public validateResponse<T>(schema: z.ZodSchema<T>, data: unknown): z.SafeParseReturnType<unknown, T> { return schema.safeParse(data); } /** * Make a type-safe request that validates the response against a schema * * @param method - HTTP method * @param url - The endpoint URL * @param schema - Zod schema to validate response against * @param data - Request payload (for POST, PUT, PATCH) * @param config - Optional Axios request configuration * @returns Promise resolving to validated response data */ public async requestWithValidation<T>( method: HttpMethod, url: string, schema: z.ZodSchema<T>, data?: any, config?: AxiosRequestConfig ): Promise<T> { const response = await this.request(method, url, data, config); return validateTallyResponse(schema, response.data); } /** * Make a generic HTTP request with interceptor-based authentication and error handling * * @param method - HTTP method * @param url - The endpoint URL (relative to base URL) * @param data - Request payload (for POST, PUT, PATCH) * @param config - Optional Axios request configuration * @returns Promise resolving to the API response */ private async request<T = any>( method: HttpMethod, url: string, data?: any, config?: AxiosRequestConfig ): Promise<ApiResponse<T>> { const requestConfig: AxiosRequestConfig = { method, url, data, ...config, }; // The interceptors will handle authentication and error handling const response: AxiosResponse<T> = await this.axiosInstance.request(requestConfig); return { data: response.data, status: response.status, statusText: response.statusText, headers: response.headers as Record<string, string>, }; } /** * Ensure the client is authenticated before making requests */ private async ensureAuthenticated(): Promise<void> { // If manual token is set, we're good if (this.config.accessToken) { return; } // If we have a token manager, get the current token if (this.tokenManager) { const accessToken = await this.tokenManager.getAccessToken(); if (accessToken) { this.axiosInstance.defaults.headers.common['Authorization'] = `Bearer ${accessToken}`; return; } } // No valid authentication found throw new AuthenticationError('No valid authentication found. Please authenticate first.', 401, 'Unauthorized'); } /** * Get the underlying Axios instance for advanced usage * * @returns The configured Axios instance */ public getAxiosInstance(): AxiosInstance { return this.axiosInstance; } /** * Get the current client configuration * * @returns The current configuration object */ public getConfig(): Readonly<Omit<Required<TallyApiClientConfig>, 'oauth2Config' | 'tokenStorage'> & { oauth2Config?: OAuth2Config; tokenStorage?: TokenStorage; }> { return { ...this.config }; } /** * Get the token manager instance (if configured) */ public getTokenManager(): TokenManager | undefined { return this.tokenManager; } /** * Calculate exponential backoff delay with jitter * * @param attempt - Current attempt number (0-based) * @param baseDelay - Base delay in milliseconds * @param exponentialBase - Exponential base multiplier * @param maxDelay - Maximum delay in milliseconds * @param jitterFactor - Jitter factor for randomness * @returns Delay in milliseconds */ private calculateBackoffDelay( attempt: number, baseDelay: number, exponentialBase: number, maxDelay: number, jitterFactor: number ): number { // Calculate exponential delay: baseDelay * (exponentialBase ^ attempt) const exponentialDelay = baseDelay * Math.pow(exponentialBase, attempt); // Apply maximum delay cap const cappedDelay = Math.min(exponentialDelay, maxDelay); // Add jitter to prevent thundering herd effect // Jitter range: [cappedDelay * (1 - jitterFactor), cappedDelay * (1 + jitterFactor)] const jitterRange = cappedDelay * jitterFactor; const jitter = (Math.random() - 0.5) * 2 * jitterRange; return Math.max(0, Math.floor(cappedDelay + jitter)); } /** * Check if a request should be retried based on error type and circuit breaker state * * @param error - The error that occurred * @param attempt - Current attempt number (0-based) * @returns True if the request should be retried */ private shouldRetry(error: any, attempt: number): boolean { const { retryConfig } = this.config; // Don't retry if we've reached the maximum attempts if (attempt >= retryConfig.maxAttempts!) { return false; } // Check circuit breaker state if (retryConfig.enableCircuitBreaker && this.circuitBreakerState === CircuitBreakerState.OPEN) { // Check if we should transition to half-open if (this.lastFailureTime && Date.now() - this.lastFailureTime.getTime() >= retryConfig.circuitBreakerTimeout!) { this.circuitBreakerState = CircuitBreakerState.HALF_OPEN; if (this.config.debug) { console.log('[TallyApiClient] Circuit breaker transitioning to HALF_OPEN'); } } else { return false; // Circuit is still open } } // Only retry for retryable errors if (error instanceof TallyApiError) { return error.isRetryable; } // Retry for network errors and timeouts if (error instanceof NetworkError || error instanceof TimeoutError) { return true; } return false; } /** * Update circuit breaker state based on request success/failure * * @param success - Whether the request was successful */ private updateCircuitBreakerState(success: boolean): void { const { retryConfig } = this.config; if (!retryConfig.enableCircuitBreaker) { return; } if (success) { // Reset failure count and close circuit on success this.consecutiveFailures = 0; if (this.circuitBreakerState !== CircuitBreakerState.CLOSED) { this.circuitBreakerState = CircuitBreakerState.CLOSED; if (this.config.debug) { console.log('[TallyApiClient] Circuit breaker CLOSED after successful request'); } } } else { // Increment failure count this.consecutiveFailures++; this.lastFailureTime = new Date(); // Open circuit if threshold is reached if (this.consecutiveFailures >= retryConfig.circuitBreakerThreshold! && this.circuitBreakerState === CircuitBreakerState.CLOSED) { this.circuitBreakerState = CircuitBreakerState.OPEN; if (this.config.debug) { console.log(`[TallyApiClient] Circuit breaker OPENED after ${this.consecutiveFailures} consecutive failures`); } } } } /** * Sleep for the specified number of milliseconds * * @param ms - Milliseconds to sleep */ private sleep(ms: number): Promise<void> { return new Promise(resolve => setTimeout(resolve, ms)); } /** * Handle response errors with retry logic and circuit breaker * * @param error - The axios error that occurred * @param attempt - Current attempt number (0-based) * @returns Promise that resolves with the response or rejects with the final error */ private async handleResponseError(error: AxiosError, attempt: number): Promise<any> { const { retryConfig } = this.config; if (this.config.debug) { console.error(`[TallyApiClient] Response Error (attempt ${attempt + 1}):`, error.response?.status, error.response?.statusText); } // Update circuit breaker state on failure this.updateCircuitBreakerState(false); // Handle network errors if (!error.response) { const networkError = this.createNetworkError(error); // Check if we should retry network errors if (this.shouldRetry(networkError, attempt)) { return this.retryRequest(error, attempt); } return Promise.reject(networkError); } const { status, statusText, headers, data } = error.response; // Handle authentication errors with automatic token refresh if (status === 401 || status === 403) { // Try to refresh token if we have a token manager if (this.tokenManager && attempt === 0) { // Only try refresh on first attempt try { await this.refreshToken(); // Retry the original request with the new token const originalRequest = error.config; if (originalRequest) { return this.axiosInstance.request(originalRequest); } } catch (refreshError) { // If refresh fails, clear authentication await this.clearAuthentication(); return Promise.reject( new AuthenticationError( 'Authentication failed and token refresh failed', status, statusText, headers as Record<string, string>, refreshError as Error ) ); } } // If no token manager or refresh failed, throw authentication error const authError = createErrorFromResponse( status, statusText, headers as Record<string, string>, data, error ); return Promise.reject(authError); } // Handle rate limiting (429) with exponential backoff if (status === 429) { const rateLimitError = createErrorFromResponse( status, statusText, headers as Record<string, string>, data, error ) as RateLimitError; if (this.shouldRetry(rateLimitError, attempt)) { // Calculate backoff delay - use retry-after header if available let delay: number; if (rateLimitError.retryAfter) { // API provided retry-after in seconds delay = rateLimitError.retryAfter * 1000; } else { // Use exponential backoff delay = this.calculateBackoffDelay( attempt, retryConfig.baseDelayMs!, retryConfig.exponentialBase!, retryConfig.maxDelayMs!, retryConfig.jitterFactor! ); } if (this.config.debug) { console.log(`[TallyApiClient] Rate limited. Retrying in ${delay}ms (attempt ${attempt + 1}/${retryConfig.maxAttempts})`); } // Wait for the calculated delay then retry await this.sleep(delay); return this.retryRequest(error, attempt); } return Promise.reject(rateLimitError); } // Handle other retryable errors (5xx server errors) const standardizedError = createErrorFromResponse( status, statusText, headers as Record<string, string>, data, error ); if (this.shouldRetry(standardizedError, attempt)) { return this.retryRequest(error, attempt); } return Promise.reject(standardizedError); } /** * Retry a failed request with exponential backoff * * @param originalError - The original axios error * @param attempt - Current attempt number (0-based) * @returns Promise that resolves with the response or rejects with the final error */ private async retryRequest(originalError: AxiosError, attempt: number): Promise<any> { const { retryConfig } = this.config; const nextAttempt = attempt + 1; // Calculate delay for non-rate-limit retries const delay = this.calculateBackoffDelay( attempt, retryConfig.baseDelayMs!, retryConfig.exponentialBase!, retryConfig.maxDelayMs!, retryConfig.jitterFactor! ); if (this.config.debug) { console.log(`[TallyApiClient] Retrying request in ${delay}ms (attempt ${nextAttempt + 1}/${retryConfig.maxAttempts})`); } // Wait for the calculated delay await this.sleep(delay); // Retry the original request const originalRequest = originalError.config; if (originalRequest) { try { return await this.axiosInstance.request(originalRequest); } catch (retryError) { // Recursively handle the retry error return this.handleResponseError(retryError as AxiosError, nextAttempt); } } // If we can't retry, reject with the original error return Promise.reject(originalError); } /** * Create appropriate network error based on error code * * @param error - The axios error * @returns Appropriate TallyApiError instance */ private createNetworkError(error: AxiosError): TallyApiError { if (error.code === 'ECONNABORTED' || error.code === 'ENOTFOUND') { return new NetworkError('Network connection failed', error); } if (error.code === 'ECONNRESET' || error.message?.includes('timeout')) { return new TimeoutError('Request timeout', error); } return new NetworkError('Network error occurred', error); } /** * Setup request/response interceptors for authentication and error handling */ private setupInterceptors(): void { // Request interceptor for automatic authentication and logging this.axiosInstance.interceptors.request.use( async (config) => { // Ensure we have a valid token before making the request await this.ensureAuthenticated(); // Debug logging if enabled if (this.config.debug) { console.log(`[TallyApiClient] Request: ${config.method?.toUpperCase()} ${config.url}`); if (config.data) { console.log(`[TallyApiClient] Request Data:`, config.data); } } return config; }, (error) => { if (this.config.debug) { console.error('[TallyApiClient] Request Error:', error); } // Convert to appropriate error type if (error.code === 'ECONNABORTED' || error.code === 'ENOTFOUND') { return Promise.reject(new NetworkError('Network connection failed', error)); } if (error.code === 'ECONNRESET' || error.message?.includes('timeout')) { return Promise.reject(new TimeoutError('Request timeout', error)); } return Promise.reject(error); } ); // Response interceptor for standardized error handling, retry logic, and logging this.axiosInstance.interceptors.response.use( (response) => { // Update circuit breaker state on success this.updateCircuitBreakerState(true); // Debug logging if enabled if (this.config.debug) { console.log(`[TallyApiClient] Response: ${response.status} ${response.statusText}`); } return response; }, async (error: AxiosError) => { return this.handleResponseError(error, 0); } ); } public async inviteUserToWorkspace(workspaceId: string, email: string, role: string): Promise<any> { console.warn('Tally API does not officially support inviting users via the API. This is a placeholder.'); // const url = `/workspaces/${workspaceId}/members`; // return this.post(url, { email, role }); return Promise.resolve({ success: true, message: `User ${email} invited to workspace ${workspaceId} as ${role}. (Mocked)` }); } public async removeUserFromWorkspace(workspaceId: string, userId: string): Promise<any> { console.warn('Tally API does not officially support removing users via the API. This is a placeholder.'); // const url = `/workspaces/${workspaceId}/members/${userId}`; // return this.delete(url); return Promise.resolve({ success: true, message: `User ${userId} removed from workspace ${workspaceId}. (Mocked)` }); } public async updateUserRole(workspaceId: string, userId: string, role: string): Promise<any> { console.warn('Tally API does not officially support updating user roles via the API. This is a placeholder.'); // const url = `/workspaces/${workspaceId}/members/${userId}`; // return this.patch(url, { role }); return Promise.resolve({ success: true, message: `User ${userId} role updated to ${role} in workspace ${workspaceId}. (Mocked)` }); } /** * Helper to check if mock API is enabled */ private isMockEnabled(): boolean { return process.env.USE_MOCK_API === 'true'; } }

Implementation Reference

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/learnwithcc/tally-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server