API Integration Guide
Version: 1.0.0 Last Updated: 2025-01-13 Audience: Backend developers integrating third-party APIs
Table of Contents
- Overview
- Core Integration Patterns
- Step-by-Step Integration Example
- Error Handling
- Rate Limiting & Retry Strategies
- Authentication Patterns
- Testing Guide
- Best Practices
- 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
- Singleton Services: API service classes use the singleton pattern for efficient resource management
- Interceptor-Based Logging: Request/response logging via Axios interceptors
- Standardized Error Handling: Custom
ApiErrorclass with type-safe error codes - Token Management: Automatic token refresh with encryption at rest
- Retry Logic: Configurable exponential backoff for transient failures
- 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
- 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,
});
}
- Log errors with context:
logger.error('Failed to sync messages', {
connectionId,
provider: 'yelp',
error: error.message,
stack: error.stack,
});
- 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
-
Yelp API Integration:
/lib/connections/yelp/yelpApiService.ts- OAuth 2.0 flow
- Business search and details
- Review fetching
- Rate limit handling
-
Google Business Profile:
/lib/connections/google/googleApiService.ts- Token refresh logic
- Automatic retry on 401
- Review sync with pagination
- Tenant-isolated encryption
-
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:
- Check existing implementations in
/lib/connections/ - Review test files in
/tests/lib/connections/ - Consult the error handling documentation in
/lib/utils/errorHandlingCore.ts - Refer to provider-specific documentation:
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:
| Provider | Events | Path |
|---|---|---|
| Yelp | Messages, reviews | /api/webhooks/yelp/* |
| Twilio | Inbound SMS, status | /api/webhooks/twilio/* |
| Messenger, comments | /api/webhooks/facebook | |
| DMs, mentions | /api/webhooks/instagram | |
| Messages | /api/webhooks/whatsapp | |
| Reviews, calendar | /api/webhooks/google | |
| RingCentral | Calls, voicemail | /api/webhooks/ringcentral |
| Mailgun | Email events | /api/webhooks/mailgun |
| Sentry | Error 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
| Endpoint | Method | Purpose |
|---|---|---|
/api/webhooks/yelp | POST | General Yelp events |
/api/webhooks/yelp/messages | POST | Message notifications |
/api/webhooks/yelp/verify | GET | Webhook verification |
Event Types: message_received, message_sent, review_created
Twilio Webhooks
| Endpoint | Method | Purpose |
|---|---|---|
/api/webhooks/twilio | POST | Inbound SMS |
/api/webhooks/twilio/status | POST | Message delivery status |
Body Format: application/x-www-form-urlencoded
Facebook Webhooks
| Endpoint | Method | Purpose |
|---|---|---|
/api/webhooks/facebook | GET | Verification challenge |
/api/webhooks/facebook | POST | Page events |
Events: messages, messaging_postbacks, feed
Instagram Webhooks
| Endpoint | Method | Purpose |
|---|---|---|
/api/webhooks/instagram | GET | Verification |
/api/webhooks/instagram | POST | Instagram events |
Events: messages, mentions, comments
WhatsApp Webhooks
| Endpoint | Method | Purpose |
|---|---|---|
/api/webhooks/whatsapp | GET | Verification |
/api/webhooks/whatsapp | POST | WhatsApp 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