• Skip to main content
  • Skip to navigation
  • Skip to search
    Petunia™
    FeaturesPricingIntegrationsAboutContact
    Log inStart free trialSign up
    Loading
    Petunia™

    Reimagining customer communication for the modern business.

    Product

    • Features
    • Pricing
    • Integrations
    • Roadmap
    • What's New

    Resources

    • Help Center
    • Documentation
    • Guides
    • API Reference
    • Community
    • Support

    Company

    • About Us
    • Careers
    • Blog
    • Press
    • Contact

    © 2026 Gray Group International LLC. All rights reserved.·
    Made by gardenpatch 🌱

    Privacy PolicyTerms of ServiceCookie Policy

    Petunia™ is a trademark of Gray Group International LLC. The Petunia name, brand, product design, and content are proprietary. Unauthorized use, imitation, or copying is prohibited.

    Documentation

    API_INTEGRATION_GUIDE

    docs/API_INTEGRATION_GUIDE.md
    Docs homeGuidesSupport
    Quick links
    Start here
    How the docs are organized.
    Environment setup
    Configure env + run locally.
    Unified Inbox
    Inbox concepts & behavior.
    Voice AI setup
    Providers, Twilio, testing.
    Pricing model
    Source-of-truth pricing.
    Operations runbook
    How to operate safely.

    API Integration Guide

    Version: 1.0.0 Last Updated: 2025-01-13 Audience: Backend developers integrating third-party APIs

    Table of Contents

    1. Overview
    2. Core Integration Patterns
    3. Step-by-Step Integration Example
    4. Error Handling
    5. Rate Limiting & Retry Strategies
    6. Authentication Patterns
    7. Testing Guide
    8. Best Practices
    9. Reference Implementations

    Overview

    Petunia integrates with multiple third-party APIs (Yelp, Google Business Profile, Facebook) to provide unified business communication and review management. This guide documents the established patterns, best practices, and architectural decisions for API integrations.

    Key Design Principles

    1. Singleton Services: API service classes use the singleton pattern for efficient resource management
    2. Interceptor-Based Logging: Request/response logging via Axios interceptors
    3. Standardized Error Handling: Custom ApiError class with type-safe error codes
    4. Token Management: Automatic token refresh with encryption at rest
    5. Retry Logic: Configurable exponential backoff for transient failures
    6. Type Safety: Full TypeScript coverage with strict types

    Core Integration Patterns

    1. API Service Architecture

    All third-party API integrations follow this structure:

    /lib/connections/{provider}/
    ├── {provider}ApiService.ts       # Core API client (singleton)
    ├── {provider}ConnectionService.ts # Database operations
    ├── {provider}MessageService.ts    # Message sync logic
    ├── {provider}ReviewService.ts     # Review sync logic
    └── {provider}WebhookService.ts    # Webhook handlers
    

    2. Service Class Structure

    Every API service follows this pattern:

    // lib/connections/yelp/yelpApiService.ts
    import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
    import { ApiError } from '@/lib/utils/errorHandlingCore';
    import { createLogger } from '@/lib/utils/logging';
    
    const logger = createLogger('yelpApiService');
    
    export class YelpApiService {
      private baseUrl: string;
      private apiVersion: string;
      private defaultAccessToken: string | null;
      private axiosInstance: AxiosInstance;
    
      constructor() {
        this.baseUrl = process.env['NEXT_PUBLIC_YELP_API_URL'] || 'https://api.yelp.com';
        this.apiVersion = 'v3';
        this.defaultAccessToken = null;
    
        // Create axios instance with base configuration
        this.axiosInstance = axios.create({
          baseURL: `${this.baseUrl}/${this.apiVersion}`,
          timeout: 10000, // 10 second timeout
        });
    
        this.setupInterceptors();
      }
    
      private setupInterceptors(): void {
        // Request interceptor for logging
        this.axiosInstance.interceptors.request.use(config => {
          logger.debug(`Making API request to: ${config.url}`);
          return config;
        });
    
        // Response interceptor for error handling
        this.axiosInstance.interceptors.response.use(
          response => response,
          error => {
            if (error.response) {
              logger.error(`API error: ${error.response.status}`, {
                data: error.response.data,
              });
            } else if (error.request) {
              logger.error('API error: No response received', { error });
            } else {
              logger.error(`API error: ${error.message}`, { error });
            }
            return Promise.reject(error);
          }
        );
      }
    
      // Authentication helper
      private createRequestConfig(accessToken?: string): AxiosRequestConfig {
        const token = accessToken || this.defaultAccessToken;
    
        if (!token) {
          throw new ApiError({
            statusCode: 401,
            message: 'No access token provided',
            errorCode: 'MISSING_ACCESS_TOKEN',
          });
        }
    
        return {
          headers: {
            Authorization: `Bearer ${token}`,
            Accept: 'application/json',
          },
        };
      }
    
      // Example API method
      async getBusinessById(businessId: string, accessToken?: string): Promise<YelpBusiness | null> {
        try {
          const config = this.createRequestConfig(accessToken);
          const response = await this.axiosInstance.get(`/businesses/${businessId}`, config);
          return response.data;
        } catch (error) {
          if (axios.isAxiosError(error) && error.response?.status === 404) {
            logger.warn(`Business with ID ${businessId} not found`);
            return null;
          }
    
          logger.error(`Error fetching business details for ID ${businessId}`, error);
          throw new ApiError({
            statusCode: 500,
            message: 'Failed to fetch business details',
            errorCode: 'YELP_API_ERROR',
            cause: error,
          });
        }
      }
    }
    
    // Export singleton instance
    export const yelpApiService = new YelpApiService();
    

    3. Type Definitions

    Define comprehensive TypeScript interfaces for API requests and responses:

    // Request parameters
    export interface YelpSearchParams {
      term?: string;
      location?: string;
      latitude?: number;
      longitude?: number;
      radius?: number;
      categories?: string;
      limit?: number;
      offset?: number;
      sort_by?: 'best_match' | 'rating' | 'review_count' | 'distance';
      accessToken: string; // Required for authentication
    }
    
    // Response types
    export interface YelpBusiness {
      id: string;
      alias: string;
      name: string;
      image_url?: string;
      is_closed: boolean;
      url: string;
      review_count: number;
      categories: Array<{ alias: string; title: string }>;
      rating: number;
      coordinates: {
        latitude: number;
        longitude: number;
      };
      location: {
        address1?: string;
        city?: string;
        state?: string;
        zip_code?: string;
        country?: string;
        display_address: string[];
      };
      phone: string;
      display_phone: string;
    }
    
    export interface YelpSearchResponse {
      businesses: YelpBusiness[];
      total: number;
      region: {
        center: {
          longitude: number;
          latitude: number;
        };
      };
    }
    

    Step-by-Step Integration Example

    Let's integrate a hypothetical "Instagram Business" API.

    Step 1: Create Directory Structure

    mkdir -p lib/connections/instagram
    touch lib/connections/instagram/instagramApiService.ts
    touch lib/connections/instagram/instagramConnectionService.ts
    touch lib/connections/instagram/instagramMessageService.ts
    

    Step 2: Define Types

    // lib/types/instagram.ts
    export interface InstagramProfile {
      id: string;
      username: string;
      name: string;
      profile_picture_url?: string;
      followers_count: number;
      follows_count: number;
      media_count: number;
      biography?: string;
    }
    
    export interface InstagramMessage {
      id: string;
      from: {
        id: string;
        username: string;
      };
      to: {
        id: string;
        username: string;
      };
      message: string;
      timestamp: string;
      attachments?: Array<{
        type: 'image' | 'video';
        url: string;
      }>;
    }
    
    export interface InstagramApiConfig {
      clientId: string;
      clientSecret: string;
      redirectUri: string;
      apiVersion: string;
    }
    

    Step 3: Implement API Service

    // lib/connections/instagram/instagramApiService.ts
    import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
    import { ApiError } from '@/lib/utils/errorHandlingCore';
    import { createLogger } from '@/lib/utils/logging';
    import type { InstagramProfile, InstagramMessage, InstagramApiConfig } from '@/lib/types/instagram';
    
    const logger = createLogger('instagramApiService');
    
    export class InstagramApiService {
      private baseUrl: string;
      private apiVersion: string;
      private axiosInstance: AxiosInstance;
      private defaultAccessToken: string | null = null;
    
      constructor(config?: Partial<InstagramApiConfig>) {
        this.baseUrl = 'https://graph.instagram.com';
        this.apiVersion = config?.apiVersion || 'v18.0';
    
        this.axiosInstance = axios.create({
          baseURL: `${this.baseUrl}/${this.apiVersion}`,
          timeout: 15000, // 15 second timeout for Instagram
        });
    
        this.setupInterceptors();
      }
    
      private setupInterceptors(): void {
        // Request logging
        this.axiosInstance.interceptors.request.use(config => {
          logger.debug(`Instagram API request: ${config.method?.toUpperCase()} ${config.url}`);
          return config;
        });
    
        // Error handling
        this.axiosInstance.interceptors.response.use(
          response => response,
          error => {
            if (error.response) {
              const { status, data } = error.response;
              logger.error(`Instagram API error: ${status}`, {
                error: data.error,
                message: data.error?.message,
              });
            } else {
              logger.error('Instagram API network error', { error: error.message });
            }
            return Promise.reject(error);
          }
        );
      }
    
      setDefaultAccessToken(token: string): void {
        this.defaultAccessToken = token;
      }
    
      private createRequestConfig(accessToken?: string): AxiosRequestConfig {
        const token = accessToken || this.defaultAccessToken;
    
        if (!token) {
          throw new ApiError({
            statusCode: 401,
            message: 'No access token provided for Instagram API',
            errorCode: 'MISSING_ACCESS_TOKEN',
          });
        }
    
        return {
          params: { access_token: token },
        };
      }
    
      /**
       * Get profile information
       */
      async getProfile(accessToken?: string): Promise<InstagramProfile> {
        try {
          const config = this.createRequestConfig(accessToken);
          const fields = 'id,username,name,profile_picture_url,followers_count,follows_count,media_count,biography';
    
          const response = await this.axiosInstance.get('/me', {
            ...config,
            params: {
              ...config.params,
              fields,
            },
          });
    
          return response.data;
        } catch (error) {
          logger.error('Failed to fetch Instagram profile', { error });
          throw new ApiError({
            statusCode: 500,
            message: 'Failed to fetch Instagram profile',
            errorCode: 'INSTAGRAM_API_ERROR',
            cause: error,
          });
        }
      }
    
      /**
       * Get messages (conversations)
       */
      async getMessages(limit = 25, accessToken?: string): Promise<InstagramMessage[]> {
        try {
          const config = this.createRequestConfig(accessToken);
    
          const response = await this.axiosInstance.get('/me/conversations', {
            ...config,
            params: {
              ...config.params,
              fields: 'id,participants,messages{id,from,to,message,created_time,attachments}',
              limit,
            },
          });
    
          return response.data.data || [];
        } catch (error) {
          logger.error('Failed to fetch Instagram messages', { error });
          throw new ApiError({
            statusCode: 500,
            message: 'Failed to fetch Instagram messages',
            errorCode: 'INSTAGRAM_API_ERROR',
            cause: error,
          });
        }
      }
    
      /**
       * Send a message
       */
      async sendMessage(recipientId: string, message: string, accessToken?: string): Promise<{ message_id: string }> {
        try {
          const config = this.createRequestConfig(accessToken);
    
          const response = await this.axiosInstance.post('/me/messages', {
            recipient: { id: recipientId },
            message: { text: message },
          }, config);
    
          return response.data;
        } catch (error) {
          logger.error('Failed to send Instagram message', { error, recipientId });
          throw new ApiError({
            statusCode: 500,
            message: 'Failed to send Instagram message',
            errorCode: 'INSTAGRAM_API_ERROR',
            cause: error,
          });
        }
      }
    }
    
    // Export singleton instance
    export const instagramApiService = new InstagramApiService();
    

    Step 4: Create Database Service

    // lib/connections/instagram/instagramConnectionService.ts
    import { prisma } from '@/lib/database';
    import { encrypt } from '@/lib/utils/secureFieldsUtils';
    import { ApiError, ErrorType } from '@/lib/utils/errorHandlingCore';
    import { logger } from '@/lib/utils/logging';
    
    export interface InstagramConnectionCreateParams {
      profileId: string;
      username: string;
      accessToken: string;
      refreshToken?: string;
      tokenExpiresAt?: Date;
      connectionIntegrationId: string;
    }
    
    export class InstagramConnectionService {
      /**
       * Create a new Instagram connection
       */
      async createConnection(params: InstagramConnectionCreateParams) {
        try {
          const { accessToken, refreshToken, connectionIntegrationId, ...rest } = params;
    
          // Get clientId for tenant-isolated encryption
          const connectionIntegration = await prisma.connectionIntegration.findUnique({
            where: { id: connectionIntegrationId },
            select: { clientId: true },
          });
    
          if (!connectionIntegration) {
            throw new ApiError({
              message: 'ConnectionIntegration not found',
              status: 404,
            });
          }
    
          const encryptionOptions = { tenantId: connectionIntegration.clientId };
    
          // Encrypt sensitive credentials with tenant isolation
          const connection = await prisma.instagramConnection.create({
            data: {
              ...rest,
              accessToken: await encrypt(accessToken, encryptionOptions),
              refreshToken: refreshToken ? await encrypt(refreshToken, encryptionOptions) : null,
              connectionIntegrationId,
            },
          });
    
          logger.info('Instagram connection created', { connectionId: connection.id });
          return connection;
        } catch (error) {
          logger.error('Failed to create Instagram connection', { error });
          throw error;
        }
      }
    
      /**
       * Get connection by ID
       */
      async getConnectionById(id: string) {
        return await prisma.instagramConnection.findUnique({
          where: { id },
          include: {
            connectionIntegration: {
              include: {
                client: true,
                connection: true,
              },
            },
          },
        });
      }
    }
    
    export const instagramConnectionService = new InstagramConnectionService();
    

    Step 5: Add API Routes

    // app/api/connections/instagram/route.ts
    import { NextRequest, NextResponse } from 'next/server';
    import { instagramApiService } from '@/lib/connections/instagram/instagramApiService';
    import { instagramConnectionService } from '@/lib/connections/instagram/instagramConnectionService';
    import { NextRouteHandlerWrapper } from '@/lib/utils/errorHandlingCore';
    
    async function POST(request: NextRequest) {
      const body = await request.json();
      const { code, connectionIntegrationId } = body;
    
      // Exchange code for access token (implement OAuth flow)
      const { access_token, user_id, username } = await exchangeCodeForToken(code);
    
      // Create connection
      const connection = await instagramConnectionService.createConnection({
        profileId: user_id,
        username,
        accessToken: access_token,
        connectionIntegrationId,
      });
    
      return NextResponse.json({ success: true, connection });
    }
    
    async function GET(request: NextRequest) {
      const { searchParams } = new URL(request.url);
      const connectionId = searchParams.get('connectionId');
    
      if (!connectionId) {
        return NextResponse.json({ error: 'connectionId required' }, { status: 400 });
      }
    
      const connection = await instagramConnectionService.getConnectionById(connectionId);
    
      if (!connection) {
        return NextResponse.json({ error: 'Connection not found' }, { status: 404 });
      }
    
      return NextResponse.json({ success: true, connection });
    }
    
    // Wrap handlers with error handling
    export { NextRouteHandlerWrapper(POST) as POST, NextRouteHandlerWrapper(GET) as GET };
    

    Error Handling

    Standard Error Types

    Petunia uses a centralized error handling system with standardized error types:

    import { ApiError, ErrorType } from '@/lib/utils/errorHandlingCore';
    
    // Authentication errors
    throw new ApiError({
      statusCode: 401,
      message: 'Access token expired',
      errorCode: ErrorType.AUTHENTICATION_ERROR,
    });
    
    // Rate limiting
    throw new ApiError({
      statusCode: 429,
      message: 'Rate limit exceeded',
      errorCode: ErrorType.RATE_LIMIT_EXCEEDED,
    });
    
    // Not found
    throw new ApiError({
      statusCode: 404,
      message: 'Resource not found',
      errorCode: ErrorType.NOT_FOUND_ERROR,
    });
    
    // Validation errors
    throw new ApiError({
      statusCode: 400,
      message: 'Invalid input parameters',
      errorCode: ErrorType.VALIDATION_ERROR,
      details: { field: 'email', reason: 'Invalid format' },
    });
    
    // External service errors
    throw new ApiError({
      statusCode: 502,
      message: 'External API unavailable',
      errorCode: ErrorType.EXTERNAL_SERVICE_ERROR,
    });
    

    Error Response Format

    All API errors return a standardized format:

    {
      "success": false,
      "error": {
        "message": "Failed to fetch business details",
        "type": "YELP_API_ERROR",
        "code": 500,
        "errorId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
        "timestamp": "2025-01-13T10:30:00.000Z"
      },
      "requestId": "req_abc123"
    }
    

    Error Handling Best Practices

    1. Always catch specific error types first:
    try {
      const response = await apiService.getData();
    } catch (error) {
      // Check for Axios errors first
      if (axios.isAxiosError(error)) {
        if (error.response?.status === 404) {
          // Handle not found
          return null;
        }
        if (error.response?.status === 401) {
          // Refresh token and retry
          await refreshAccessToken();
          return apiService.getData();
        }
      }
    
      // Generic error handling
      logger.error('API request failed', { error });
      throw new ApiError({
        statusCode: 500,
        message: 'Failed to fetch data',
        errorCode: 'API_ERROR',
        cause: error,
      });
    }
    
    1. Log errors with context:
    logger.error('Failed to sync messages', {
      connectionId,
      provider: 'yelp',
      error: error.message,
      stack: error.stack,
    });
    
    1. Use type guards for error checking:
    import { hasHttpResponse } from '@/lib/utils/errorHandlingCore';
    
    if (hasHttpResponse(error)) {
      const status = error.response.status;
      const data = error.response.data;
      // Handle HTTP error
    }
    

    Rate Limiting & Retry Strategies

    Automatic Retry with Exponential Backoff

    Use the withRetry utility for automatic retries:

    import { withRetry, RetryOptions } from '@/lib/utils/errorHandlingCore';
    
    const options: RetryOptions = {
      retries: 3,              // Retry up to 3 times
      delay: 500,              // Start with 500ms delay
      backoffFactor: 2,        // Double the delay each time
      maxDelay: 30000,         // Cap at 30 seconds
      useJitter: true,         // Add randomness to prevent thundering herd
      onRetry: (error, attempt, delay) => {
        logger.info(`Retrying API call (attempt ${attempt})`, { delay });
      },
      retryableError: (error) => {
        // Only retry on specific errors
        if (axios.isAxiosError(error)) {
          const status = error.response?.status;
          return status === 429 || status === 503 || status === 500;
        }
        return false;
      },
    };
    
    // Wrap your API call with retry logic
    const business = await withRetry(
      () => yelpApiService.getBusinessById(businessId, token),
      options
    );
    

    Using retryFetch for HTTP Requests

    For direct fetch calls, use retryFetch:

    import { retryFetch, RetryOptions } from '@/lib/utils/retry-fetch';
    
    const options: RetryOptions = {
      maxRetries: 3,
      initialDelay: 1000,
      maxDelay: 10000,
      backoffFactor: 2,
      retryOn: [408, 429, 500, 502, 503, 504], // HTTP status codes to retry
      onRetry: (attempt, error) => {
        logger.warn(`Retrying request (attempt ${attempt})`, { error });
      },
    };
    
    const response = await retryFetch(
      'https://api.example.com/data',
      {
        method: 'GET',
        headers: {
          'Authorization': `Bearer ${token}`,
        },
      },
      options
    );
    

    Rate Limit Handling

    Google, Yelp, and Facebook APIs enforce rate limits. Handle them gracefully:

    async getBusinessDetails(businessId: string, accessToken?: string): Promise<YelpBusiness> {
      try {
        return await withRetry(
          () => this.axiosInstance.get(`/businesses/${businessId}`, this.createRequestConfig(accessToken)),
          {
            retries: 3,
            delay: 1000,
            retryableError: (error) => {
              // Retry on rate limit (429) with exponential backoff
              if (axios.isAxiosError(error) && error.response?.status === 429) {
                const retryAfter = error.response.headers['retry-after'];
                if (retryAfter) {
                  // Use the Retry-After header if provided
                  const retryDelayMs = parseInt(retryAfter, 10) * 1000;
                  logger.info(`Rate limited. Retrying after ${retryDelayMs}ms`);
                }
                return true;
              }
              return false;
            },
          }
        );
      } catch (error) {
        logger.error('Failed to fetch business details after retries', { businessId, error });
        throw new ApiError({
          statusCode: 500,
          message: 'Failed to fetch business details',
          errorCode: 'YELP_API_ERROR',
          cause: error,
        });
      }
    }
    

    Retry Queue for Background Jobs

    For long-running sync operations, use the retry queue service:

    import { retryQueueService } from '@/lib/utils/retryQueueService';
    
    // Add failed operation to retry queue
    try {
      await syncYelpMessages(connectionId);
    } catch (error) {
      logger.error('Message sync failed, adding to retry queue', { error });
    
      retryQueueService.addToQueue({
        operation: 'syncYelpMessages',
        payload: { connectionId },
        maxRetries: 5,
        lastError: error,
        metadata: {
          provider: 'yelp',
          timestamp: new Date().toISOString(),
        },
      });
    }
    

    Authentication Patterns

    OAuth 2.0 Flow

    All integrations (Yelp, Google, Facebook) use OAuth 2.0. Here's the standard flow:

    1. Authorization URL Generation

    export class YelpApiService {
      getAuthorizationUrl(clientId: string, redirectUri: string, state: string): string {
        const params = new URLSearchParams({
          client_id: clientId,
          redirect_uri: redirectUri,
          response_type: 'code',
          state, // CSRF protection
        });
    
        return `https://www.yelp.com/oauth2/authorize?${params.toString()}`;
      }
    }
    

    2. Token Exchange

    async exchangeAuthCode(code: string, clientId: string, clientSecret: string, redirectUri: string) {
      try {
        const response = await fetch('https://api.yelp.com/oauth2/token', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/x-www-form-urlencoded',
          },
          body: new URLSearchParams({
            grant_type: 'authorization_code',
            code,
            client_id: clientId,
            client_secret: clientSecret,
            redirect_uri: redirectUri,
          }).toString(),
        });
    
        if (!response.ok) {
          throw new ApiError({
            message: 'Failed to exchange authorization code',
            status: response.status,
          });
        }
    
        const data = await response.json();
        return {
          access_token: data.access_token,
          refresh_token: data.refresh_token,
          expires_in: data.expires_in,
          token_type: data.token_type,
        };
      } catch (error) {
        logger.error('Token exchange failed', { error });
        throw error;
      }
    }
    

    3. Token Refresh

    async refreshAccessToken(refreshToken: string): Promise<{ access_token: string; expires_in: number }> {
      try {
        const response = await fetch(`${GOOGLE_API_URLS.AUTH}/token`, {
          method: 'POST',
          headers: {
            'Content-Type': 'application/x-www-form-urlencoded',
          },
          body: new URLSearchParams({
            client_id: this.config.clientId,
            client_secret: this.config.clientSecret,
            refresh_token: refreshToken,
            grant_type: 'refresh_token',
          }).toString(),
        });
    
        if (!response.ok) {
          throw new ApiError({
            message: 'Failed to refresh access token',
            status: response.status,
          });
        }
    
        const data = await response.json();
    
        // Update database with new token
        if (this.googleConnection) {
          const expiresAt = new Date();
          expiresAt.setSeconds(expiresAt.getSeconds() + data.expires_in);
    
          const clientId = await this.getClientId();
          const encryptionOptions = clientId ? { tenantId: clientId } : undefined;
    
          await prisma.googleConnection.update({
            where: { id: this.googleConnection.id },
            data: {
              accessToken: await encrypt(data.access_token, encryptionOptions),
              tokenExpiresAt: expiresAt,
            },
          });
        }
    
        return {
          access_token: data.access_token,
          expires_in: data.expires_in,
        };
      } catch (error) {
        logger.error('Error refreshing access token', { error });
        throw error;
      }
    }
    

    4. Automatic Token Refresh in API Calls

    async makeAuthenticatedRequest<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
      if (!this.googleConnection?.accessToken) {
        throw new ApiError({
          message: 'No access token available',
          status: 401,
        });
      }
    
      try {
        const accessToken = await this.decryptCredential(this.googleConnection.accessToken);
        const url = `${this.baseUrl}${endpoint}`;
    
        const response = await fetch(url, {
          ...options,
          headers: {
            'Authorization': `Bearer ${accessToken}`,
            'Content-Type': 'application/json',
            ...options.headers,
          },
        });
    
        // Auto-refresh on 401
        if (response.status === 401 && this.googleConnection.refreshToken) {
          logger.info('Token expired, refreshing...');
    
          const decryptedRefreshToken = await this.decryptCredential(
            this.googleConnection.refreshToken
          );
          const { access_token } = await this.refreshAccessToken(decryptedRefreshToken);
    
          // Retry with new token
          const retryResponse = await fetch(url, {
            ...options,
            headers: {
              'Authorization': `Bearer ${access_token}`,
              'Content-Type': 'application/json',
              ...options.headers,
            },
          });
    
          if (!retryResponse.ok) {
            throw new ApiError({
              message: `API error: ${retryResponse.statusText}`,
              status: retryResponse.status,
            });
          }
    
          return await retryResponse.json();
        }
    
        if (!response.ok) {
          throw new ApiError({
            message: `API error: ${response.statusText}`,
            status: response.status,
          });
        }
    
        return await response.json();
      } catch (error) {
        logger.error('Authenticated request failed', { error, endpoint });
        throw error;
      }
    }
    

    Secure Credential Storage

    Always encrypt tokens at rest using tenant-isolated encryption:

    import { encrypt, decrypt } from '@/lib/utils/secureFieldsUtils';
    
    // Encrypt with tenant isolation (HKDF-derived keys)
    const encryptedToken = await encrypt(accessToken, { tenantId: clientId });
    
    // Store in database
    await prisma.yelpConnection.update({
      where: { id: connectionId },
      data: {
        accessToken: encryptedToken,
      },
    });
    
    // Decrypt when needed
    const decryptedToken = await decrypt(encryptedToken, { tenantId: clientId });
    

    Testing Guide

    Unit Testing API Services

    Use Jest with mocked Axios instances:

    // tests/lib/connections/instagram/instagramApiService.test.ts
    import axios from 'axios';
    import { InstagramApiService } from '@/lib/connections/instagram/instagramApiService';
    import { ApiError } from '@/lib/utils/errorHandlingCore';
    
    // Mock axios
    jest.mock('axios');
    const mockedAxios = axios as jest.Mocked<typeof axios>;
    
    const mockAxiosInstance = {
      get: jest.fn(),
      post: jest.fn(),
      interceptors: {
        request: { use: jest.fn() },
        response: { use: jest.fn() },
      },
    };
    
    mockedAxios.create.mockReturnValue(mockAxiosInstance as any);
    
    describe('InstagramApiService', () => {
      let service: InstagramApiService;
    
      beforeEach(() => {
        jest.clearAllMocks();
        service = new InstagramApiService();
      });
    
      describe('getProfile', () => {
        it('should fetch profile successfully', async () => {
          const mockProfile = {
            id: '12345',
            username: 'testuser',
            name: 'Test User',
            followers_count: 1000,
          };
    
          mockAxiosInstance.get.mockResolvedValue({ data: mockProfile });
    
          const result = await service.getProfile('test-token');
    
          expect(result).toEqual(mockProfile);
          expect(mockAxiosInstance.get).toHaveBeenCalledWith('/me', {
            params: {
              access_token: 'test-token',
              fields: expect.stringContaining('id,username'),
            },
          });
        });
    
        it('should throw ApiError on failure', async () => {
          mockAxiosInstance.get.mockRejectedValue(new Error('Network error'));
    
          await expect(service.getProfile('test-token')).rejects.toThrow(ApiError);
        });
    
        it('should throw error when no token provided', async () => {
          await expect(service.getProfile()).rejects.toThrow('No access token provided');
        });
      });
    
      describe('sendMessage', () => {
        it('should send message successfully', async () => {
          const mockResponse = { message_id: 'msg_123' };
          mockAxiosInstance.post.mockResolvedValue({ data: mockResponse });
    
          const result = await service.sendMessage('recipient_123', 'Hello!', 'token');
    
          expect(result).toEqual(mockResponse);
          expect(mockAxiosInstance.post).toHaveBeenCalledWith(
            '/me/messages',
            {
              recipient: { id: 'recipient_123' },
              message: { text: 'Hello!' },
            },
            expect.objectContaining({
              params: { access_token: 'token' },
            })
          );
        });
      });
    });
    

    Integration Testing

    Test end-to-end flows with API route handlers:

    // tests/api/connections/instagram.test.ts
    import { POST, GET } from '@/app/api/connections/instagram/route';
    import { instagramConnectionService } from '@/lib/connections/instagram/instagramConnectionService';
    
    jest.mock('@/lib/connections/instagram/instagramConnectionService');
    
    describe('Instagram Connection API', () => {
      describe('POST /api/connections/instagram', () => {
        it('should create connection successfully', async () => {
          const mockConnection = {
            id: 'conn_123',
            profileId: 'profile_456',
            username: 'testuser',
          };
    
          (instagramConnectionService.createConnection as jest.Mock).mockResolvedValue(mockConnection);
    
          const request = new Request('http://localhost/api/connections/instagram', {
            method: 'POST',
            body: JSON.stringify({
              code: 'auth_code_123',
              connectionIntegrationId: 'ci_789',
            }),
          });
    
          const response = await POST(request);
          const data = await response.json();
    
          expect(response.status).toBe(200);
          expect(data.success).toBe(true);
          expect(data.connection).toEqual(mockConnection);
        });
    
        it('should handle errors gracefully', async () => {
          (instagramConnectionService.createConnection as jest.Mock).mockRejectedValue(
            new Error('Database error')
          );
    
          const request = new Request('http://localhost/api/connections/instagram', {
            method: 'POST',
            body: JSON.stringify({
              code: 'auth_code_123',
              connectionIntegrationId: 'ci_789',
            }),
          });
    
          const response = await POST(request);
          const data = await response.json();
    
          expect(response.status).toBe(500);
          expect(data.success).toBe(false);
          expect(data.error).toBeDefined();
        });
      });
    });
    

    Testing Retry Logic

    describe('Retry Logic', () => {
      it('should retry on 429 rate limit', async () => {
        let callCount = 0;
    
        mockAxiosInstance.get.mockImplementation(() => {
          callCount++;
          if (callCount < 3) {
            const error: any = new Error('Rate limited');
            error.response = { status: 429, headers: { 'retry-after': '1' } };
            error.isAxiosError = true;
            throw error;
          }
          return Promise.resolve({ data: { id: 'success' } });
        });
    
        const result = await withRetry(
          () => service.getProfile('token'),
          {
            retries: 3,
            delay: 100, // Short delay for tests
            retryableError: (error) => error.response?.status === 429,
          }
        );
    
        expect(callCount).toBe(3);
        expect(result.id).toBe('success');
      });
    
      it('should not retry on non-retryable errors', async () => {
        mockAxiosInstance.get.mockRejectedValue({
          response: { status: 400 },
          isAxiosError: true,
        });
    
        await expect(
          withRetry(
            () => service.getProfile('token'),
            {
              retries: 3,
              retryableError: (error) => error.response?.status === 429,
            }
          )
        ).rejects.toThrow();
    
        expect(mockAxiosInstance.get).toHaveBeenCalledTimes(1);
      });
    });
    

    Best Practices

    1. Use Singleton Pattern

    Export a single instance of your API service to prevent multiple axios instances:

    // ✅ Good
    export const yelpApiService = new YelpApiService();
    
    // ❌ Bad
    export class YelpApiService { ... }
    // Each import creates a new instance
    

    2. Type Everything

    Use TypeScript interfaces for all request/response shapes:

    // ✅ Good
    interface SearchParams {
      query: string;
      limit?: number;
    }
    
    async search(params: SearchParams): Promise<SearchResult> { ... }
    
    // ❌ Bad
    async search(params: any): Promise<any> { ... }
    

    3. Centralized Configuration

    Store API configuration in environment variables:

    const DEFAULT_CONFIG: InstagramApiConfig = {
      clientId: process.env['INSTAGRAM_CLIENT_ID'] || '',
      clientSecret: process.env['INSTAGRAM_CLIENT_SECRET'] || '',
      redirectUri: process.env['INSTAGRAM_REDIRECT_URI'] || `${env.PUBLIC.SITE_URL}/api/auth/instagram/callback`,
      apiVersion: process.env['INSTAGRAM_API_VERSION'] || 'v18.0',
    };
    

    4. Log Strategically

    Log at appropriate levels:

    // ✅ Good
    logger.debug('Making API request', { url, method }); // Verbose, only in dev
    logger.info('Connection created', { connectionId });  // Important events
    logger.warn('Rate limit hit, retrying', { retryAfter }); // Recoverable issues
    logger.error('API request failed', { error, context }); // Errors
    
    // ❌ Bad
    console.log('Request:', url); // Don't use console.log
    logger.info('Variable x =', x); // Don't log every variable
    

    5. Handle Null/Undefined Gracefully

    // ✅ Good
    async getBusinessById(businessId: string, accessToken?: string): Promise<YelpBusiness | null> {
      try {
        const response = await this.axiosInstance.get(`/businesses/${businessId}`, config);
        return response.data;
      } catch (error) {
        if (axios.isAxiosError(error) && error.response?.status === 404) {
          logger.warn(`Business not found: ${businessId}`);
          return null; // Explicit null for "not found"
        }
        throw error; // Re-throw other errors
      }
    }
    
    // ❌ Bad
    async getBusinessById(businessId: string): Promise<YelpBusiness> {
      const response = await this.axiosInstance.get(`/businesses/${businessId}`);
      return response.data; // Throws on 404, no way to distinguish "not found" from "error"
    }
    

    6. Use Axios Interceptors for Cross-Cutting Concerns

    Don't repeat logging/error handling in every method:

    // ✅ Good - Centralized in interceptors
    this.axiosInstance.interceptors.response.use(
      response => response,
      error => {
        logger.error('API error', { error });
        return Promise.reject(error);
      }
    );
    
    // ❌ Bad - Repeated in every method
    async getProfile() {
      try {
        const response = await this.axiosInstance.get('/profile');
        logger.info('Profile fetched');
        return response.data;
      } catch (error) {
        logger.error('Failed to fetch profile', { error });
        throw error;
      }
    }
    

    7. Validate API Responses

    Don't assume API responses match your types:

    // ✅ Good
    async getProfile(): Promise<InstagramProfile> {
      const response = await this.axiosInstance.get('/me');
      const data = response.data;
    
      // Validate critical fields
      if (!data.id || !data.username) {
        throw new ApiError({
          message: 'Invalid profile data received',
          statusCode: 500,
          errorCode: 'INVALID_RESPONSE',
          details: data,
        });
      }
    
      return data;
    }
    

    8. Use Environment-Specific Timeouts

    Different environments have different network characteristics:

    const timeout = process.env['NODE_ENV'] === 'production'
      ? 10000  // 10s in production
      : 30000; // 30s in development (debugging)
    
    this.axiosInstance = axios.create({
      baseURL: this.baseUrl,
      timeout,
    });
    

    9. Document Rate Limits

    Add comments about API rate limits:

    /**
     * Get business details from Yelp
     *
     * Rate Limit: 5000 requests/day per API key
     * Rate Limit Header: X-RateLimit-Remaining
     *
     * @param businessId - Yelp business ID
     * @param accessToken - OAuth access token
     * @returns Business details or null if not found
     */
    async getBusinessById(businessId: string, accessToken?: string): Promise<YelpBusiness | null> {
      // Implementation
    }
    

    10. Use Idempotency Keys for Critical Operations

    For operations that shouldn't be repeated (payments, etc.):

    async sendMessage(recipientId: string, message: string, accessToken?: string): Promise<any> {
      const idempotencyKey = uuidv4();
    
      const config = this.createRequestConfig(accessToken);
      config.headers = {
        ...config.headers,
        'Idempotency-Key': idempotencyKey,
      };
    
      return await this.axiosInstance.post('/messages', {
        recipient: { id: recipientId },
        message: { text: message },
      }, config);
    }
    

    Reference Implementations

    Complete Examples

    1. Yelp API Integration: /lib/connections/yelp/yelpApiService.ts

      • OAuth 2.0 flow
      • Business search and details
      • Review fetching
      • Rate limit handling
    2. Google Business Profile: /lib/connections/google/googleApiService.ts

      • Token refresh logic
      • Automatic retry on 401
      • Review sync with pagination
      • Tenant-isolated encryption
    3. Facebook Graph API: /lib/connections/facebook/facebookApiService.ts

      • Conversation sync
      • Message sending
      • Page management
      • Webhook subscriptions

    Directory Structure Reference

    /lib/connections/{provider}/
    ├── {provider}ApiService.ts          # Main API client
    ├── {provider}ConnectionService.ts   # Database operations
    ├── {provider}MessageService.ts      # Message sync
    ├── {provider}MessageSync.ts         # Message sync worker
    ├── {provider}ReviewService.ts       # Review operations
    ├── {provider}ReviewSync.ts          # Review sync worker
    └── {provider}WebhookService.ts      # Webhook processing
    
    /lib/types/
    └── {provider}.ts                    # TypeScript type definitions
    
    /app/api/connections/{provider}/
    ├── route.ts                         # Create/list connections
    └── callback/route.ts                # OAuth callback handler
    
    /app/api/webhooks/{provider}/
    └── route.ts                         # Webhook endpoint
    
    /tests/lib/connections/{provider}/
    ├── {provider}ApiService.test.ts
    ├── {provider}ConnectionService.test.ts
    └── {provider}Integration.test.ts
    
    /tests/app/api/connections/
    └── {provider}.test.ts
    
    /tests/app/api/webhooks/
    └── {provider}.test.ts
    

    Additional Resources

    • Error Handling Core: /lib/utils/errorHandlingCore.ts
    • Retry Utilities: /lib/utils/retry-fetch.ts, /lib/utils/retryQueueService.ts
    • Secure Encryption: /lib/utils/secureFieldsUtils.ts
    • Logging: /lib/utils/logging.ts
    • Testing Guide: /TESTING.md
    • API Routes Convention: /CLAUDE.md (API route structure section)

    Questions & Support

    For questions about API integrations:

    1. Check existing implementations in /lib/connections/
    2. Review test files in /tests/lib/connections/
    3. Consult the error handling documentation in /lib/utils/errorHandlingCore.ts
    4. Refer to provider-specific documentation:
      • Yelp Fusion API
      • Google Business Profile API
      • Facebook Graph API

    Document Version: 2.0.0 Last Updated: 2026-01-23 Maintained By: Petunia Engineering Team


    Webhook Integration

    This section covers webhook architecture, security patterns, and integration guides for all external webhook providers.

    Webhook Overview

    Petunia receives webhooks from multiple external providers to handle real-time events:

    ProviderEventsPath
    YelpMessages, reviews/api/webhooks/yelp/*
    TwilioInbound SMS, status/api/webhooks/twilio/*
    FacebookMessenger, comments/api/webhooks/facebook
    InstagramDMs, mentions/api/webhooks/instagram
    WhatsAppMessages/api/webhooks/whatsapp
    GoogleReviews, calendar/api/webhooks/google
    RingCentralCalls, voicemail/api/webhooks/ringcentral
    MailgunEmail events/api/webhooks/mailgun
    SentryError alerts/api/webhooks/sentry

    Webhook Request Flow

    External Provider
           │
           ▼
    ┌──────────────────┐
    │  Next.js Route   │
    │  (webhook/*)     │
    ├──────────────────┤
    │ 1. Verify Sig    │
    │ 2. Parse Body    │
    │ 3. Idempotency   │
    │ 4. Process Event │
    │ 5. Respond 200   │
    └──────────────────┘
           │
           ▼
    ┌──────────────────┐
    │ Business Logic   │
    │ (async if slow)  │
    └──────────────────┘
    

    Webhook Security Architecture

    Signature Validation Patterns

    All webhooks MUST validate signatures before processing. Each provider uses different signing methods:

    HMAC-SHA1 (Twilio)
    import crypto from 'crypto';
    
    function validateTwilioSignature(
      request: NextRequest,
      body: Record<string, string>
    ): boolean {
      const signature = request.headers.get('x-twilio-signature');
      const authToken = process.env['TWILIO_AUTH_TOKEN'];
    
      if (!signature || !authToken) {
        return process.env['NODE_ENV'] !== 'production';
      }
    
      const url = process.env['TWILIO_WEBHOOK_URL'] ||
        `${process.env['NEXT_PUBLIC_SITE_URL']}/api/webhooks/twilio`;
    
      // Sort parameters alphabetically
      const sortedParams = Object.keys(body)
        .sort()
        .reduce((acc, key) => acc + key + body[key], url);
    
      const expectedSignature = crypto
        .createHmac('sha1', authToken)
        .update(Buffer.from(sortedParams, 'utf-8'))
        .digest('base64');
    
      return signature === expectedSignature;
    }
    
    HMAC-SHA256 (Facebook/Instagram/WhatsApp)
    import crypto from 'crypto';
    
    function verifyFacebookSignature(
      signature: string | null,
      body: string
    ): boolean {
      if (!signature) return false;
    
      const appSecret = process.env['FACEBOOK_APP_SECRET'];
      if (!appSecret) return false;
    
      const expectedSignature = 'sha256=' + crypto
        .createHmac('sha256', appSecret)
        .update(body)
        .digest('hex');
    
      return crypto.timingSafeEqual(
        Buffer.from(signature),
        Buffer.from(expectedSignature)
      );
    }
    
    Token Comparison (Yelp)
    function verifyYelpWebhook(
      signature: string | null,
      body: string,
      secret: string
    ): boolean {
      // Development bypass
      if (process.env['NODE_ENV'] === 'development' &&
          process.env['SKIP_WEBHOOK_VERIFICATION'] === 'true') {
        return true;
      }
    
      if (!signature || !secret) return false;
      return signature === secret;
    }
    
    API Key Header (Sentry)
    function verifySentryWebhook(request: NextRequest): boolean {
      const sentrySecret = request.headers.get('sentry-hook-signature');
      return sentrySecret === process.env['SENTRY_WEBHOOK_SECRET'];
    }
    

    Required Environment Variables for Webhooks

    # Twilio
    TWILIO_AUTH_TOKEN=your_auth_token
    TWILIO_WEBHOOK_URL=https://app.yourdomain.com/api/webhooks/twilio
    
    # Facebook/Instagram/WhatsApp (Meta)
    FACEBOOK_APP_SECRET=your_app_secret
    FACEBOOK_VERIFY_TOKEN=your_verify_token
    
    # Yelp
    YELP_WEBHOOK_SECRET=your_webhook_secret
    
    # Google
    GOOGLE_WEBHOOK_SECRET=your_webhook_secret
    
    # Mailgun
    MAILGUN_WEBHOOK_SIGNING_KEY=your_signing_key
    
    # Sentry
    SENTRY_WEBHOOK_SECRET=your_webhook_secret
    

    Webhook Endpoints Reference

    Yelp Webhooks

    EndpointMethodPurpose
    /api/webhooks/yelpPOSTGeneral Yelp events
    /api/webhooks/yelp/messagesPOSTMessage notifications
    /api/webhooks/yelp/verifyGETWebhook verification

    Event Types: message_received, message_sent, review_created

    Twilio Webhooks

    EndpointMethodPurpose
    /api/webhooks/twilioPOSTInbound SMS
    /api/webhooks/twilio/statusPOSTMessage delivery status

    Body Format: application/x-www-form-urlencoded

    Facebook Webhooks

    EndpointMethodPurpose
    /api/webhooks/facebookGETVerification challenge
    /api/webhooks/facebookPOSTPage events

    Events: messages, messaging_postbacks, feed

    Instagram Webhooks

    EndpointMethodPurpose
    /api/webhooks/instagramGETVerification
    /api/webhooks/instagramPOSTInstagram events

    Events: messages, mentions, comments

    WhatsApp Webhooks

    EndpointMethodPurpose
    /api/webhooks/whatsappGETVerification
    /api/webhooks/whatsappPOSTWhatsApp events

    Events: messages, statuses

    Testing Webhooks

    Local Development with ngrok

    # Start ngrok tunnel
    ngrok http 3000
    
    # Use the HTTPS URL for webhooks
    # https://abc123.ngrok.io/api/webhooks/twilio
    

    Bypass Signature Validation (Dev Only)

    # .env.local
    NODE_ENV=development
    SKIP_WEBHOOK_VERIFICATION=true
    

    Manual Testing with cURL

    Test Twilio Webhook:

    curl -X POST http://localhost:3000/api/webhooks/twilio \
      -H "Content-Type: application/x-www-form-urlencoded" \
      -d "From=%2B15551234567&To=%2B15559876543&Body=Test%20message"
    

    Test Yelp Webhook:

    curl -X POST http://localhost:3000/api/webhooks/yelp/messages \
      -H "Content-Type: application/json" \
      -H "x-yelp-signature: your_secret" \
      -d '{"event_type":"message_received","business_id":"abc123"}'
    

    Webhook Idempotency

    Why Idempotency Matters

    Webhook providers may retry failed deliveries. Without idempotency, you might:

    • Process the same message twice
    • Create duplicate records
    • Charge customers multiple times

    Implementation Pattern

    // 1. Extract unique identifier from webhook
    const eventId = data.message?.id || data.event_id;
    const idempotencyKey = `webhook_${provider}_${eventId}`;
    
    // 2. Check if already processed (in database)
    const existing = await prisma.webhookEvent.findUnique({
      where: { idempotencyKey },
    });
    
    if (existing) {
      logger.info('Duplicate webhook ignored', { idempotencyKey });
      return NextResponse.json({ success: true, duplicate: true });
    }
    
    // 3. Process and record atomically
    await prisma.$transaction(async (tx) => {
      // Record the event first
      await tx.webhookEvent.create({
        data: {
          idempotencyKey,
          provider,
          eventType: data.event_type,
          payload: data,
        },
      });
    
      // Then process
      await processWebhookEvent(tx, data);
    });
    

    Webhook Debugging Guide

    Common Issues

    1. Signature Validation Failures

    • Verify environment variables are set
    • Check webhook URL matches provider config
    • Ensure body isn't modified before validation
    • For Twilio: URL must match exactly (http vs https)

    2. Missing Events

    • Verify subscription in provider dashboard
    • Check webhook health status
    • Review provider webhook logs
    • Verify firewall/WAF isn't blocking

    3. Duplicate Processing

    • Use idempotency keys (see above)

    Logging Best Practices

    // Always log webhook receipt
    logger.info('Received webhook', {
      provider: 'twilio',
      eventType: data.event_type,
      messageId: data.message?.id,
    });
    
    // Log processing outcome
    logger.info('Processed webhook', {
      provider: 'twilio',
      eventType: data.event_type,
      processingTimeMs: Date.now() - startTime,
      success: true,
    });
    
    // Log failures with context
    logger.error('Webhook processing failed', {
      provider: 'twilio',
      eventType: data.event_type,
      error: error.message,
      stack: error.stack,
    });
    

    Webhook Health Monitoring

    Health Check Endpoint

    GET /api/webhooks/health
    

    Returns status of webhook processing system:

    {
      "status": "healthy",
      "providers": {
        "twilio": { "lastEvent": "2025-12-13T10:30:00Z", "status": "active" },
        "yelp": { "lastEvent": "2025-12-13T10:25:00Z", "status": "active" },
        "facebook": { "lastEvent": "2025-12-13T09:00:00Z", "status": "active" }
      }
    }
    

    Monitoring Alerts

    Configure alerts for:

    • No webhook events for >1 hour (per provider)
    • High error rate (>5% failures)
    • Processing latency >5s
    • Signature validation failures spike
    On this page
    Table of ContentsOverviewKey Design PrinciplesCore Integration Patterns1. API Service Architecture2. Service Class Structure3. Type DefinitionsStep-by-Step Integration ExampleStep 1: Create Directory StructureStep 2: Define TypesStep 3: Implement API ServiceStep 4: Create Database ServiceStep 5: Add API RoutesError HandlingStandard Error TypesError Response FormatError Handling Best PracticesRate Limiting & Retry StrategiesAutomatic Retry with Exponential BackoffUsing retryFetch for HTTP RequestsRate Limit HandlingRetry Queue for Background JobsAuthentication PatternsOAuth 2.0 FlowSecure Credential StorageTesting GuideUnit Testing API ServicesIntegration TestingTesting Retry LogicBest Practices1. Use Singleton Pattern2. Type Everything