Custom API Integration for ChatGPT Apps: REST, GraphQL, Webhooks

Integrate ChatGPT with any API (REST, GraphQL, SOAP) for unlimited custom functionality. Whether you're connecting to Stripe for payments, Salesforce for CRM data, or custom internal APIs, mastering API integration patterns unlocks industry-specific workflows that transform ChatGPT from a general assistant into a specialized business tool.

Developers integrate 100+ custom APIs with ChatGPT apps, enabling real-time inventory lookups, automated customer support, dynamic pricing engines, and multi-system orchestration. This guide covers REST APIs for CRUD operations, GraphQL for flexible data fetching, and webhooks for real-time event-driven updates—complete with authentication, error handling, rate limiting, and production-ready code.

By the end of this guide, you'll implement secure, scalable API integrations that handle millions of requests, gracefully retry failures, and respect rate limits—all while maintaining ChatGPT's conversational UX.

Related Resources:

  • Building Production-Ready ChatGPT Apps: OpenAI Apps SDK Complete Guide
  • MCP Server Development: Tools, Resources, Prompts
  • ChatGPT App Authentication: OAuth 2.1 Implementation Guide

API Integration Patterns

REST APIs (Representational State Transfer) are the most common integration pattern, using HTTP methods (GET, POST, PUT, DELETE) for CRUD operations. REST APIs excel at resource-oriented operations with predictable endpoints like /users/:id, /orders, /products?category=electronics. They're ideal for simple integrations where you know exactly what data you need upfront.

GraphQL APIs provide flexible, client-driven queries through a single endpoint. Instead of multiple REST calls to assemble data from related resources (users, orders, products), GraphQL lets you request precisely the fields you need in one query. This reduces over-fetching and under-fetching, making GraphQL perfect for complex data models with nested relationships.

Webhooks enable real-time event notifications from external systems. Rather than polling an API every minute to check for updates (inefficient), webhooks push data to your MCP server when events occur (order placed, payment received, ticket created). This pattern is essential for time-sensitive workflows and reduces API quota usage by 90%.

When to use each pattern:

  • REST: CRUD operations, simple data retrieval, standard HTTP interactions
  • GraphQL: Complex data models, nested relationships, mobile apps (minimize requests)
  • Webhooks: Real-time notifications, event-driven workflows, reducing polling overhead

Most production ChatGPT apps combine all three: REST for basic operations, GraphQL for complex queries, webhooks for real-time updates.

Related: API Architecture Best Practices for ChatGPT Apps


Prerequisites

Before implementing API integrations, ensure you have:

  1. MCP Server Development Environment

    • Node.js 18+ or Python 3.10+ installed
    • TypeScript compiler (for Node.js projects)
    • MCP SDK installed (npm install @modelcontextprotocol/sdk or pip install mcp)
    • Local testing setup with MCP Inspector
  2. API Credentials and Documentation

    • API keys, OAuth client ID/secret, or JWT tokens
    • API documentation (endpoints, authentication, rate limits)
    • Sandbox/test environment for development
    • Webhook secret for signature verification (if using webhooks)
  3. HTTP and API Fundamentals

    • Understanding of HTTP methods (GET, POST, PUT, PATCH, DELETE)
    • Status codes (200, 201, 400, 401, 403, 404, 429, 500, 502, 503)
    • Headers (Authorization, Content-Type, Accept)
    • Request/response body formats (JSON, XML, form-encoded)

Pro Tip: Use Postman or Insomnia to test API endpoints manually before integrating them into your MCP server. This helps you understand request/response formats and debug authentication issues.

Related: MCP Server Setup: Development Environment Configuration


REST API Integration

REST APIs power the majority of integrations in ChatGPT apps. Here's how to implement robust, production-ready REST integrations with proper error handling and rate limiting.

Step 1: Design MCP Tools for REST Endpoints

Map REST endpoints to MCP tools using a consistent naming convention. Each CRUD operation becomes a tool that ChatGPT can invoke.

// MCP Tool definitions for a REST API (Customer Management)
const tools = [
  {
    name: "getCustomer",
    description: "Retrieve customer details by ID from CRM",
    inputSchema: {
      type: "object",
      properties: {
        customerId: {
          type: "string",
          description: "Unique customer identifier (UUID or numeric ID)"
        }
      },
      required: ["customerId"]
    }
  },
  {
    name: "listCustomers",
    description: "List customers with optional filtering and pagination",
    inputSchema: {
      type: "object",
      properties: {
        page: {
          type: "number",
          description: "Page number (1-indexed)",
          default: 1
        },
        limit: {
          type: "number",
          description: "Results per page (max 100)",
          default: 20
        },
        status: {
          type: "string",
          enum: ["active", "inactive", "pending"],
          description: "Filter by customer status"
        },
        search: {
          type: "string",
          description: "Search customers by name or email"
        }
      }
    }
  },
  {
    name: "createCustomer",
    description: "Create a new customer record in CRM",
    inputSchema: {
      type: "object",
      properties: {
        name: {
          type: "string",
          description: "Customer full name"
        },
        email: {
          type: "string",
          format: "email",
          description: "Customer email address"
        },
        phone: {
          type: "string",
          description: "Phone number (E.164 format)"
        },
        company: {
          type: "string",
          description: "Company name (optional)"
        }
      },
      required: ["name", "email"]
    }
  },
  {
    name: "updateCustomer",
    description: "Update existing customer details",
    inputSchema: {
      type: "object",
      properties: {
        customerId: {
          type: "string",
          description: "Customer ID to update"
        },
        updates: {
          type: "object",
          description: "Fields to update (partial update supported)"
        }
      },
      required: ["customerId", "updates"]
    }
  },
  {
    name: "deleteCustomer",
    description: "Soft delete customer (marks as inactive)",
    inputSchema: {
      type: "object",
      properties: {
        customerId: {
          type: "string",
          description: "Customer ID to delete"
        }
      },
      required: ["customerId"]
    }
  }
];

Best Practices:

  • Use verb-noun naming (getCustomer, not customer_get)
  • Provide detailed descriptions for each parameter
  • Mark required fields explicitly
  • Include validation constraints (enums, formats, ranges)

Step 2: Implement HTTP Client

Configure an HTTP client with base URL, default headers, timeout, and retry logic.

import axios, { AxiosInstance, AxiosError } from 'axios';

// HTTP client configuration
class APIClient {
  private client: AxiosInstance;
  private baseURL: string;
  private apiKey: string;

  constructor(baseURL: string, apiKey: string) {
    this.baseURL = baseURL;
    this.apiKey = apiKey;

    this.client = axios.create({
      baseURL: this.baseURL,
      timeout: 30000, // 30 second timeout
      headers: {
        'Content-Type': 'application/json',
        'Accept': 'application/json',
        'User-Agent': 'MakeAIHQ-MCP/1.0'
      },
      maxRedirects: 5,
      validateStatus: (status) => status >= 200 && status < 500 // Don't throw on 4xx
    });

    // Request interceptor for authentication
    this.client.interceptors.request.use((config) => {
      config.headers.Authorization = `Bearer ${this.apiKey}`;
      return config;
    });

    // Response interceptor for logging and error handling
    this.client.interceptors.response.use(
      (response) => {
        console.log(`✅ ${response.config.method?.toUpperCase()} ${response.config.url} - ${response.status}`);
        return response;
      },
      (error: AxiosError) => {
        if (error.response) {
          console.error(`❌ ${error.config?.method?.toUpperCase()} ${error.config?.url} - ${error.response.status}`);
        } else if (error.request) {
          console.error(`❌ No response received: ${error.message}`);
        } else {
          console.error(`❌ Request setup error: ${error.message}`);
        }
        return Promise.reject(error);
      }
    );
  }

  async get(path: string, params?: object) {
    const response = await this.client.get(path, { params });
    return response.data;
  }

  async post(path: string, data: object) {
    const response = await this.client.post(path, data);
    return response.data;
  }

  async put(path: string, data: object) {
    const response = await this.client.put(path, data);
    return response.data;
  }

  async delete(path: string) {
    const response = await this.client.delete(path);
    return response.data;
  }
}

// Initialize client
const apiClient = new APIClient(
  process.env.API_BASE_URL || 'https://api.example.com',
  process.env.API_KEY || ''
);

Configuration Options:

  • baseURL: API endpoint (configure per environment)
  • timeout: Prevent hanging requests (30s recommended)
  • maxRedirects: Follow redirects automatically
  • validateStatus: Control which HTTP codes throw errors

Related: Environment Variables and Secret Management for MCP Servers

Step 3: Authentication Patterns

Implement the authentication method required by your API (API key, Bearer token, OAuth 2.0).

// Authentication interceptor with multiple patterns
class AuthManager {
  private authType: 'api-key' | 'bearer' | 'oauth';
  private credentials: {
    apiKey?: string;
    token?: string;
    clientId?: string;
    clientSecret?: string;
    accessToken?: string;
    refreshToken?: string;
    expiresAt?: number;
  };

  constructor(authType: 'api-key' | 'bearer' | 'oauth', credentials: object) {
    this.authType = authType;
    this.credentials = credentials;
  }

  async getAuthHeaders(): Promise<Record<string, string>> {
    switch (this.authType) {
      case 'api-key':
        // API Key in header or query param
        return {
          'X-API-Key': this.credentials.apiKey || '',
          // Alternative: 'Authorization': `ApiKey ${this.credentials.apiKey}`
        };

      case 'bearer':
        // Bearer token (JWT, personal access token)
        return {
          'Authorization': `Bearer ${this.credentials.token || ''}`
        };

      case 'oauth':
        // OAuth 2.0 with automatic token refresh
        if (this.isTokenExpired()) {
          await this.refreshAccessToken();
        }
        return {
          'Authorization': `Bearer ${this.credentials.accessToken || ''}`
        };

      default:
        return {};
    }
  }

  private isTokenExpired(): boolean {
    if (!this.credentials.expiresAt) return false;
    return Date.now() >= this.credentials.expiresAt;
  }

  private async refreshAccessToken(): Promise<void> {
    const response = await axios.post('https://oauth.example.com/token', {
      grant_type: 'refresh_token',
      refresh_token: this.credentials.refreshToken,
      client_id: this.credentials.clientId,
      client_secret: this.credentials.clientSecret
    });

    this.credentials.accessToken = response.data.access_token;
    this.credentials.refreshToken = response.data.refresh_token || this.credentials.refreshToken;
    this.credentials.expiresAt = Date.now() + (response.data.expires_in * 1000);

    console.log('✅ OAuth token refreshed successfully');
  }
}

Security Best Practices:

  • Store credentials in environment variables (NEVER hardcode)
  • Use OAuth 2.0 for user-facing apps (PKCE required for ChatGPT)
  • Rotate API keys regularly
  • Implement token refresh before expiration (not after 401)

Related: ChatGPT App Authentication: OAuth 2.1 Implementation Guide

Step 4: Error Handling

Parse API error responses and implement retry logic with exponential backoff.

import { AxiosError } from 'axios';

// Error handler with retry and exponential backoff
class APIErrorHandler {
  async handleRequest<T>(
    requestFn: () => Promise<T>,
    maxRetries: number = 3,
    retryableStatuses: number[] = [429, 500, 502, 503, 504]
  ): Promise<T> {
    let lastError: Error | null = null;

    for (let attempt = 0; attempt <= maxRetries; attempt++) {
      try {
        return await requestFn();
      } catch (error) {
        lastError = error as Error;

        if (axios.isAxiosError(error)) {
          const axiosError = error as AxiosError;

          // Non-retryable errors (client errors)
          if (axiosError.response?.status && axiosError.response.status < 500 && axiosError.response.status !== 429) {
            throw this.parseAPIError(axiosError);
          }

          // Retryable errors (server errors, rate limits)
          if (axiosError.response?.status && retryableStatuses.includes(axiosError.response.status)) {
            if (attempt < maxRetries) {
              const delayMs = this.calculateBackoff(attempt, axiosError.response.status);
              console.warn(`⚠️ Retrying request (attempt ${attempt + 1}/${maxRetries}) after ${delayMs}ms...`);
              await this.sleep(delayMs);
              continue;
            }
          }

          throw this.parseAPIError(axiosError);
        }

        // Network errors, timeouts
        if (attempt < maxRetries) {
          const delayMs = this.calculateBackoff(attempt);
          console.warn(`⚠️ Network error, retrying (attempt ${attempt + 1}/${maxRetries}) after ${delayMs}ms...`);
          await this.sleep(delayMs);
          continue;
        }

        throw lastError;
      }
    }

    throw lastError || new Error('Request failed after retries');
  }

  private calculateBackoff(attempt: number, statusCode?: number): number {
    // Respect Retry-After header for 429 Rate Limit
    if (statusCode === 429) {
      // In real implementation, parse Retry-After header from response
      return 60000; // 60 seconds default for rate limits
    }

    // Exponential backoff: 1s, 2s, 4s, 8s...
    const baseDelay = 1000;
    const maxDelay = 32000; // Cap at 32 seconds
    const delay = Math.min(baseDelay * Math.pow(2, attempt), maxDelay);

    // Add jitter to prevent thundering herd
    const jitter = Math.random() * 1000;
    return delay + jitter;
  }

  private parseAPIError(error: AxiosError): Error {
    const status = error.response?.status;
    const data = error.response?.data as any;

    const message = data?.message || data?.error || error.message || 'API request failed';

    switch (status) {
      case 400:
        return new Error(`Bad Request: ${message}`);
      case 401:
        return new Error(`Unauthorized: Invalid or expired credentials`);
      case 403:
        return new Error(`Forbidden: Insufficient permissions`);
      case 404:
        return new Error(`Not Found: Resource does not exist`);
      case 429:
        return new Error(`Rate Limit Exceeded: ${message}`);
      case 500:
        return new Error(`Internal Server Error: ${message}`);
      case 502:
        return new Error(`Bad Gateway: Upstream server error`);
      case 503:
        return new Error(`Service Unavailable: ${message}`);
      default:
        return new Error(`API Error (${status}): ${message}`);
    }
  }

  private sleep(ms: number): Promise<void> {
    return new Promise(resolve => setTimeout(resolve, ms));
  }
}

// Usage in MCP tool handler
const errorHandler = new APIErrorHandler();

async function getCustomerTool(customerId: string) {
  return errorHandler.handleRequest(async () => {
    return await apiClient.get(`/customers/${customerId}`);
  });
}

Error Handling Strategy:

  • 4xx errors (400-499): Client errors, don't retry (bad request, auth issues)
  • 429 Rate Limit: Retry with exponential backoff, respect Retry-After header
  • 5xx errors (500-599): Server errors, retry with backoff
  • Network errors: Retry with backoff (DNS failures, timeouts)

Related: Error Handling Best Practices for ChatGPT Apps

Step 5: Pagination and Rate Limiting

Implement pagination for large datasets and respect API rate limits.

// Pagination and rate limiting manager
class PaginationManager {
  private rateLimiter: RateLimiter;

  constructor(requestsPerSecond: number = 10) {
    this.rateLimiter = new RateLimiter(requestsPerSecond);
  }

  // Cursor-based pagination (recommended for large datasets)
  async fetchAllPages<T>(
    fetchPage: (cursor?: string) => Promise<{ data: T[], nextCursor?: string }>,
    maxPages: number = 10
  ): Promise<T[]> {
    let allResults: T[] = [];
    let cursor: string | undefined = undefined;
    let pageCount = 0;

    while (pageCount < maxPages) {
      await this.rateLimiter.waitForSlot();

      const response = await fetchPage(cursor);
      allResults = allResults.concat(response.data);

      if (!response.nextCursor) break; // No more pages

      cursor = response.nextCursor;
      pageCount++;
    }

    return allResults;
  }

  // Offset-based pagination (simpler but less efficient)
  async fetchAllPagesOffset<T>(
    fetchPage: (page: number, limit: number) => Promise<{ data: T[], total: number }>,
    limit: number = 100,
    maxPages: number = 10
  ): Promise<T[]> {
    let allResults: T[] = [];
    let page = 1;

    while (page <= maxPages) {
      await this.rateLimiter.waitForSlot();

      const response = await fetchPage(page, limit);
      allResults = allResults.concat(response.data);

      // Check if we've fetched all results
      if (allResults.length >= response.total) break;

      page++;
    }

    return allResults;
  }
}

// Token bucket rate limiter
class RateLimiter {
  private tokens: number;
  private maxTokens: number;
  private refillRate: number; // tokens per second
  private lastRefill: number;

  constructor(requestsPerSecond: number) {
    this.maxTokens = requestsPerSecond;
    this.tokens = requestsPerSecond;
    this.refillRate = requestsPerSecond;
    this.lastRefill = Date.now();
  }

  async waitForSlot(): Promise<void> {
    this.refillTokens();

    if (this.tokens >= 1) {
      this.tokens -= 1;
      return;
    }

    // Wait until next token is available
    const waitTime = (1 / this.refillRate) * 1000;
    await this.sleep(waitTime);
    return this.waitForSlot();
  }

  private refillTokens(): void {
    const now = Date.now();
    const elapsed = (now - this.lastRefill) / 1000;
    const tokensToAdd = elapsed * this.refillRate;

    this.tokens = Math.min(this.maxTokens, this.tokens + tokensToAdd);
    this.lastRefill = now;
  }

  private sleep(ms: number): Promise<void> {
    return new Promise(resolve => setTimeout(resolve, ms));
  }
}

// Usage example
const paginationManager = new PaginationManager(10); // 10 requests/second

async function getAllCustomers() {
  return paginationManager.fetchAllPages(async (cursor) => {
    const response = await apiClient.get('/customers', {
      cursor: cursor,
      limit: 100
    });
    return {
      data: response.customers,
      nextCursor: response.pagination?.nextCursor
    };
  }, 50); // Max 50 pages = 5,000 customers
}

Pagination Best Practices:

  • Use cursor-based pagination for large datasets (avoids skipped/duplicate records)
  • Implement client-side rate limiting (don't wait for 429 errors)
  • Respect X-RateLimit-Remaining headers
  • Cache results to reduce API calls

Related: Performance Optimization for ChatGPT Apps: Caching and Rate Limiting


GraphQL API Integration

GraphQL provides flexible, client-driven queries that reduce over-fetching and enable powerful data composition.

Step 1: Define GraphQL Client

Configure a GraphQL client with authentication and error handling.

import { GraphQLClient } from 'graphql-request';

// GraphQL client configuration
class GraphQLAPIClient {
  private client: GraphQLClient;

  constructor(endpoint: string, apiKey: string) {
    this.client = new GraphQLClient(endpoint, {
      headers: {
        'Authorization': `Bearer ${apiKey}`,
        'Content-Type': 'application/json'
      },
      timeout: 30000
    });
  }

  async query<T>(query: string, variables?: object): Promise<T> {
    try {
      return await this.client.request<T>(query, variables);
    } catch (error: any) {
      throw this.parseGraphQLError(error);
    }
  }

  private parseGraphQLError(error: any): Error {
    if (error.response?.errors) {
      const messages = error.response.errors.map((e: any) => e.message).join('; ');
      return new Error(`GraphQL Error: ${messages}`);
    }
    return new Error(`GraphQL Request Failed: ${error.message}`);
  }
}

const graphqlClient = new GraphQLAPIClient(
  process.env.GRAPHQL_ENDPOINT || 'https://api.example.com/graphql',
  process.env.API_KEY || ''
);

Step 2: Write Queries and Mutations

Define GraphQL queries for data retrieval and mutations for data modification.

// GraphQL queries and mutations
const GET_CUSTOMER = `
  query GetCustomer($id: ID!) {
    customer(id: $id) {
      id
      name
      email
      phone
      company
      orders(first: 10) {
        edges {
          node {
            id
            orderNumber
            total
            status
            createdAt
          }
        }
      }
      metadata {
        totalOrders
        lifetimeValue
        joinedAt
      }
    }
  }
`;

const LIST_CUSTOMERS = `
  query ListCustomers($first: Int, $after: String, $status: CustomerStatus, $search: String) {
    customers(first: $first, after: $after, status: $status, search: $search) {
      edges {
        node {
          id
          name
          email
          status
        }
        cursor
      }
      pageInfo {
        hasNextPage
        endCursor
      }
      totalCount
    }
  }
`;

const CREATE_CUSTOMER = `
  mutation CreateCustomer($input: CreateCustomerInput!) {
    createCustomer(input: $input) {
      customer {
        id
        name
        email
        phone
        company
      }
      errors {
        field
        message
      }
    }
  }
`;

const UPDATE_CUSTOMER = `
  mutation UpdateCustomer($id: ID!, $input: UpdateCustomerInput!) {
    updateCustomer(id: $id, input: $input) {
      customer {
        id
        name
        email
        phone
        company
      }
      errors {
        field
        message
      }
    }
  }
`;

// Reusable fragment
const CUSTOMER_FRAGMENT = `
  fragment CustomerFields on Customer {
    id
    name
    email
    phone
    company
    status
    createdAt
    updatedAt
  }
`;

GraphQL Best Practices:

  • Request only the fields you need (avoid ... on Customer { * })
  • Use fragments to avoid repeating field lists
  • Implement pagination with edges/nodes pattern
  • Include errors field in mutation responses

Step 3: Handle Variables and Arguments

Execute queries with dynamic variables from user input.

// MCP tool handlers for GraphQL API
async function getCustomerTool(customerId: string) {
  const result = await graphqlClient.query<{ customer: any }>(GET_CUSTOMER, {
    id: customerId
  });

  if (!result.customer) {
    throw new Error(`Customer ${customerId} not found`);
  }

  return {
    customer: result.customer,
    recentOrders: result.customer.orders.edges.map((edge: any) => edge.node)
  };
}

async function listCustomersTool(args: {
  limit?: number;
  cursor?: string;
  status?: string;
  search?: string;
}) {
  const result = await graphqlClient.query<{ customers: any }>(LIST_CUSTOMERS, {
    first: args.limit || 20,
    after: args.cursor,
    status: args.status,
    search: args.search
  });

  return {
    customers: result.customers.edges.map((edge: any) => edge.node),
    hasNextPage: result.customers.pageInfo.hasNextPage,
    nextCursor: result.customers.pageInfo.endCursor,
    totalCount: result.customers.totalCount
  };
}

async function createCustomerTool(args: {
  name: string;
  email: string;
  phone?: string;
  company?: string;
}) {
  const result = await graphqlClient.query<{ createCustomer: any }>(CREATE_CUSTOMER, {
    input: args
  });

  if (result.createCustomer.errors && result.createCustomer.errors.length > 0) {
    const errorMessages = result.createCustomer.errors.map((e: any) => `${e.field}: ${e.message}`).join('; ');
    throw new Error(`Validation errors: ${errorMessages}`);
  }

  return result.createCustomer.customer;
}

Variable Type Safety:

  • Use TypeScript interfaces for query variables
  • Validate input against GraphQL schema
  • Handle null/undefined gracefully (GraphQL distinguishes between null and omitted)

Related: Type-Safe API Integration with TypeScript and GraphQL

Step 4: Error Handling

Parse and categorize GraphQL errors (query errors vs field errors).

// GraphQL error parser
class GraphQLErrorHandler {
  parseErrors(error: any): { type: string; message: string; path?: string[] } {
    // Network errors
    if (!error.response) {
      return {
        type: 'NETWORK_ERROR',
        message: error.message || 'Network request failed'
      };
    }

    // GraphQL errors array
    if (error.response.errors && error.response.errors.length > 0) {
      const firstError = error.response.errors[0];

      return {
        type: firstError.extensions?.code || 'GRAPHQL_ERROR',
        message: firstError.message,
        path: firstError.path
      };
    }

    // Field-level errors (from mutations)
    if (error.response.data) {
      const mutationResult = Object.values(error.response.data)[0] as any;
      if (mutationResult?.errors && mutationResult.errors.length > 0) {
        return {
          type: 'VALIDATION_ERROR',
          message: mutationResult.errors.map((e: any) => `${e.field}: ${e.message}`).join('; ')
        };
      }
    }

    return {
      type: 'UNKNOWN_ERROR',
      message: 'An unexpected error occurred'
    };
  }
}

Error Types:

  • GRAPHQL_ERROR: Query syntax errors, field not found
  • VALIDATION_ERROR: Input validation failures
  • AUTHENTICATION_ERROR: Invalid credentials
  • AUTHORIZATION_ERROR: Insufficient permissions
  • RATE_LIMIT_ERROR: Too many requests

Related: Error Handling Best Practices for ChatGPT Apps


Webhook Integration

Webhooks enable real-time event notifications from external systems to your MCP server.

Step 1: Create Webhook Endpoint

Set up an Express.js route to receive webhook POST requests.

import express, { Request, Response } from 'express';
import crypto from 'crypto';

const app = express();

// Webhook endpoint
app.post('/webhooks/:provider', express.raw({ type: 'application/json' }), async (req: Request, res: Response) => {
  const provider = req.params.provider; // stripe, github, shopify, etc.
  const signature = req.headers['x-webhook-signature'] as string;
  const rawBody = req.body;

  try {
    // Step 1: Verify webhook signature
    const isValid = verifyWebhookSignature(provider, rawBody, signature);
    if (!isValid) {
      console.error('❌ Invalid webhook signature');
      return res.status(401).json({ error: 'Invalid signature' });
    }

    // Step 2: Parse JSON payload
    const payload = JSON.parse(rawBody.toString());

    // Step 3: Process webhook event
    await processWebhookEvent(provider, payload);

    // Step 4: Acknowledge receipt (200 OK)
    res.status(200).json({ received: true });

  } catch (error: any) {
    console.error(`❌ Webhook processing error: ${error.message}`);
    res.status(500).json({ error: 'Internal server error' });
  }
});

// Start webhook server
const PORT = process.env.WEBHOOK_PORT || 3000;
app.listen(PORT, () => {
  console.log(`✅ Webhook server listening on port ${PORT}`);
});

Webhook Security:

  • Always verify webhook signatures (prevents spoofing)
  • Use HTTPS endpoints (many providers require TLS)
  • Return 200 OK immediately (process async to avoid timeouts)
  • Implement idempotency (same event may be sent multiple times)

Step 2: Signature Verification

Validate webhook signatures using HMAC-SHA256.

// Webhook signature verification
function verifyWebhookSignature(provider: string, rawBody: Buffer, signature: string): boolean {
  const secret = getWebhookSecret(provider);

  switch (provider) {
    case 'stripe':
      // Stripe signature format: t=timestamp,v1=signature
      return verifyStripeSignature(rawBody, signature, secret);

    case 'github':
      // GitHub signature format: sha256=<hex>
      return verifyGitHubSignature(rawBody, signature, secret);

    case 'shopify':
      // Shopify signature format: base64 HMAC
      return verifyShopifySignature(rawBody, signature, secret);

    default:
      // Generic HMAC-SHA256 verification
      const expectedSignature = crypto
        .createHmac('sha256', secret)
        .update(rawBody)
        .digest('hex');
      return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature));
  }
}

function verifyStripeSignature(rawBody: Buffer, signature: string, secret: string): boolean {
  const elements = signature.split(',');
  const timestamp = elements.find(e => e.startsWith('t='))?.split('=')[1];
  const sig = elements.find(e => e.startsWith('v1='))?.split('=')[1];

  if (!timestamp || !sig) return false;

  // Verify timestamp (prevent replay attacks)
  const currentTime = Math.floor(Date.now() / 1000);
  if (currentTime - parseInt(timestamp) > 300) { // 5 minutes tolerance
    console.warn('⚠️ Webhook timestamp too old');
    return false;
  }

  // Verify signature
  const expectedSig = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex');

  return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expectedSig));
}

function getWebhookSecret(provider: string): string {
  const secrets: Record<string, string> = {
    stripe: process.env.STRIPE_WEBHOOK_SECRET || '',
    github: process.env.GITHUB_WEBHOOK_SECRET || '',
    shopify: process.env.SHOPIFY_WEBHOOK_SECRET || ''
  };
  return secrets[provider] || '';
}

Security Best Practices:

  • Use crypto.timingSafeEqual to prevent timing attacks
  • Verify timestamps to prevent replay attacks (5-minute window)
  • Store webhook secrets in environment variables
  • Rotate webhook secrets periodically

Related: Webhook Security Best Practices for ChatGPT Apps

Step 3: Event Processing

Parse webhook events and trigger ChatGPT notifications or internal state updates.

// Webhook event processor
async function processWebhookEvent(provider: string, payload: any): Promise<void> {
  console.log(`📥 Received ${provider} webhook: ${payload.type || payload.event}`);

  switch (provider) {
    case 'stripe':
      await processStripeEvent(payload);
      break;

    case 'github':
      await processGitHubEvent(payload);
      break;

    case 'shopify':
      await processShopifyEvent(payload);
      break;

    default:
      console.warn(`⚠️ Unknown provider: ${provider}`);
  }
}

async function processStripeEvent(event: any): Promise<void> {
  switch (event.type) {
    case 'checkout.session.completed':
      // Customer completed payment
      await handlePaymentSuccess(event.data.object);
      break;

    case 'customer.subscription.created':
      // New subscription started
      await handleSubscriptionCreated(event.data.object);
      break;

    case 'invoice.payment_failed':
      // Payment failed, notify customer
      await handlePaymentFailed(event.data.object);
      break;

    default:
      console.log(`ℹ️ Unhandled Stripe event: ${event.type}`);
  }
}

async function handlePaymentSuccess(session: any): Promise<void> {
  const customerId = session.customer;
  const amount = session.amount_total / 100; // Convert cents to dollars

  console.log(`✅ Payment successful: Customer ${customerId} paid $${amount}`);

  // Update internal database
  await updateCustomerSubscription(customerId, {
    status: 'active',
    lastPayment: new Date(),
    amount: amount
  });

  // Optional: Trigger ChatGPT notification
  // This could update a widget or send a prompt to the conversation
}

async function updateCustomerSubscription(customerId: string, data: any): Promise<void> {
  // Update Firestore, PostgreSQL, etc.
  console.log(`💾 Updated subscription for customer ${customerId}`);
}

Event Processing Patterns:

  • Use switch statements for event type routing
  • Implement idempotency keys (same event may arrive multiple times)
  • Update internal state asynchronously (don't block webhook response)
  • Log all events for debugging and audit trails

Step 4: Webhook Registration

Register webhook URLs with API providers programmatically.

// Webhook subscription manager
class WebhookManager {
  private apiClient: APIClient;

  constructor(apiClient: APIClient) {
    this.apiClient = apiClient;
  }

  async registerWebhook(url: string, events: string[]): Promise<string> {
    const response = await this.apiClient.post('/webhooks', {
      url: url,
      events: events,
      secret: crypto.randomBytes(32).toString('hex') // Generate webhook secret
    });

    console.log(`✅ Webhook registered: ${response.id}`);
    console.log(`🔐 Webhook secret: ${response.secret} (save this securely)`);

    return response.id;
  }

  async unregisterWebhook(webhookId: string): Promise<void> {
    await this.apiClient.delete(`/webhooks/${webhookId}`);
    console.log(`✅ Webhook ${webhookId} deleted`);
  }

  async listWebhooks(): Promise<any[]> {
    const response = await this.apiClient.get('/webhooks');
    return response.webhooks;
  }
}

// Usage
const webhookManager = new WebhookManager(apiClient);

await webhookManager.registerWebhook(
  'https://api.makeaihq.com/webhooks/stripe',
  ['checkout.session.completed', 'customer.subscription.created', 'invoice.payment_failed']
);

Webhook Registration:

  • Use public HTTPS URLs (many providers require TLS)
  • For development, use ngrok or similar tunneling service
  • Subscribe only to needed events (reduces noise)
  • Save webhook secrets securely (environment variables, secrets manager)

Related: Local Development Webhooks: ngrok and Testing


Advanced Patterns

Request Batching

Combine multiple API calls into a single request to reduce latency.

// Batch multiple API calls
async function batchGetCustomers(customerIds: string[]): Promise<any[]> {
  // GraphQL batching (fetch multiple customers in one query)
  const query = `
    query GetMultipleCustomers($ids: [ID!]!) {
      customers(ids: $ids) {
        id
        name
        email
      }
    }
  `;

  const result = await graphqlClient.query(query, { ids: customerIds });
  return result.customers;
}

Caching with Redis

Cache API responses to reduce load and improve response times.

import Redis from 'ioredis';

const redis = new Redis(process.env.REDIS_URL);

async function getCachedCustomer(customerId: string): Promise<any> {
  // Check cache first
  const cached = await redis.get(`customer:${customerId}`);
  if (cached) {
    return JSON.parse(cached);
  }

  // Cache miss, fetch from API
  const customer = await apiClient.get(`/customers/${customerId}`);

  // Cache for 5 minutes
  await redis.setex(`customer:${customerId}`, 300, JSON.stringify(customer));

  return customer;
}

Parallel Requests

Execute multiple independent API calls concurrently with Promise.all.

async function getCustomerDashboard(customerId: string) {
  const [customer, orders, invoices, support] = await Promise.all([
    apiClient.get(`/customers/${customerId}`),
    apiClient.get(`/customers/${customerId}/orders`),
    apiClient.get(`/customers/${customerId}/invoices`),
    apiClient.get(`/customers/${customerId}/support-tickets`)
  ]);

  return { customer, orders, invoices, support };
}

Related: Advanced API Patterns: Batching, Caching, Parallel Requests


Testing and Validation

Unit Tests with Mocked API Responses

import { jest } from '@jest/globals';
import axios from 'axios';

jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;

test('getCustomer returns customer data', async () => {
  mockedAxios.get.mockResolvedValue({
    data: { id: '123', name: 'John Doe', email: 'john@example.com' }
  });

  const customer = await getCustomerTool('123');

  expect(customer.id).toBe('123');
  expect(customer.name).toBe('John Doe');
});

test('getCustomer handles 404 error', async () => {
  mockedAxios.get.mockRejectedValue({
    response: { status: 404, data: { error: 'Not found' } }
  });

  await expect(getCustomerTool('999')).rejects.toThrow('Not Found');
});

Integration Tests with Sandbox APIs

Use API sandbox environments for testing without affecting production data.

const testClient = new APIClient(
  'https://sandbox.api.example.com',
  process.env.SANDBOX_API_KEY || ''
);

test('createCustomer integration test', async () => {
  const customer = await testClient.post('/customers', {
    name: 'Test Customer',
    email: 'test@example.com'
  });

  expect(customer.id).toBeDefined();
  expect(customer.name).toBe('Test Customer');
});

Webhook Testing

Test webhooks locally using ngrok or RequestBin.

# Install ngrok
npm install -g ngrok

# Expose local webhook endpoint
ngrok http 3000

# Use ngrok URL for webhook registration
# https://<random-id>.ngrok.io/webhooks/stripe

Related: Testing Strategies for ChatGPT Apps: Unit, Integration, E2E


Troubleshooting

CORS Errors

Symptom: Browser blocks requests with "CORS policy" error Cause: Server doesn't include Access-Control-Allow-Origin header Fix: Configure CORS headers on API server (not client-side issue)

// Express.js CORS configuration
import cors from 'cors';

app.use(cors({
  origin: ['https://chatgpt.com', 'https://platform.openai.com'],
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization']
}));

SSL Certificate Errors

Symptom: UNABLE_TO_VERIFY_LEAF_SIGNATURE or CERT_HAS_EXPIRED Cause: Expired or self-signed SSL certificate Fix: Use valid SSL certificate from Let's Encrypt or trusted CA

Timeout Errors

Symptom: Requests hang or fail with ETIMEDOUT Cause: API response time exceeds client timeout Fix: Increase timeout, optimize API queries, add pagination

const client = axios.create({
  timeout: 60000 // Increase to 60 seconds
});

Rate Limit Exceeded

Symptom: 429 Too Many Requests Cause: Exceeded API rate limit Fix: Implement client-side rate limiting, respect Retry-After header

Related: Debugging ChatGPT Apps: Common Issues and Solutions


Conclusion

Custom API integration unlocks unlimited functionality for ChatGPT apps, connecting conversational AI to any data source or business system. By mastering REST APIs for CRUD operations, GraphQL for flexible queries, and webhooks for real-time updates, you build production-ready integrations that handle millions of requests with proper authentication, error handling, and rate limiting.

Key Takeaways:

  • REST APIs: Design idempotent MCP tools, implement retry logic, paginate large datasets
  • GraphQL APIs: Request only needed fields, use fragments, handle field-level errors
  • Webhooks: Verify signatures, process events asynchronously, implement idempotency
  • Error Handling: Retry 5xx errors with exponential backoff, don't retry 4xx errors
  • Rate Limiting: Implement client-side throttling, respect API quotas

Ready to integrate custom APIs with your ChatGPT app? Start building with MakeAIHQ's ChatGPT app builder and connect to any REST, GraphQL, or webhook-based API in minutes—no backend coding required.

Related Resources:

  • Building Production-Ready ChatGPT Apps: OpenAI Apps SDK Complete Guide
  • MCP Server Development: Tools, Resources, Prompts
  • ChatGPT App Authentication: OAuth 2.1 Implementation Guide
  • Error Handling Best Practices for ChatGPT Apps
  • Performance Optimization for ChatGPT Apps: Caching and Rate Limiting

External References:

  1. REST API Design Best Practices
  2. GraphQL Official Specification
  3. Webhook Security Best Practices

Last updated: January 2026