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:
MCP Server Development Environment
- Node.js 18+ or Python 3.10+ installed
- TypeScript compiler (for Node.js projects)
- MCP SDK installed (
npm install @modelcontextprotocol/sdkorpip install mcp) - Local testing setup with MCP Inspector
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)
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, notcustomer_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 automaticallyvalidateStatus: 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-Afterheader - 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-Remainingheaders - 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
errorsfield 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.timingSafeEqualto 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:
Last updated: January 2026