Overview
Patterns for integrating external APIs — from RapidAPI marketplace APIs to custom REST endpoints. Focus on type safety, resilience, and debuggability.
RapidAPI Authentication
All RapidAPI calls require two headers:
const headers = {
"X-RapidAPI-Key": process.env.RAPIDAPI_KEY!,
"X-RapidAPI-Host": "api-host.p.rapidapi.com", // differs per API
};
const response = await fetch("https://api-host.p.rapidapi.com/endpoint", {
headers,
});
Store RAPIDAPI_KEY in environment variables, never in code.
Generic HTTP Client Wrapper
// lib/api-client.ts
interface ApiClientConfig {
baseUrl: string;
defaultHeaders?: Record<string, string>;
timeout?: number;
}
interface RequestOptions {
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
params?: Record<string, string | number | boolean>;
body?: unknown;
headers?: Record<string, string>;
}
async function request<T>(
config: ApiClientConfig,
path: string,
options: RequestOptions = {}
): Promise<T> {
const { method = "GET", params, body, headers = {} } = options;
const url = new URL(path, config.baseUrl);
if (params) {
Object.entries(params).forEach(([k, v]) => {
url.searchParams.set(k, String(v));
});
}
const controller = new AbortController();
const timeout = setTimeout(
() => controller.abort(),
config.timeout ?? 30_000
);
try {
const response = await fetch(url.toString(), {
method,
headers: {
"Content-Type": "application/json",
...config.defaultHeaders,
...headers,
},
body: body ? JSON.stringify(body) : undefined,
signal: controller.signal,
});
if (!response.ok) {
const text = await response.text().catch(() => "");
throw new ApiError(response.status, `${method} ${path}: ${text}`);
}
return response.json() as Promise<T>;
} finally {
clearTimeout(timeout);
}
}
class ApiError extends Error {
constructor(public status: number, message: string) {
super(message);
this.name = "ApiError";
}
}
API Client Factory
function createApiClient(config: ApiClientConfig) {
return {
get: <T>(path: string, params?: Record<string, string | number | boolean>) =>
request<T>(config, path, { params }),
post: <T>(path: string, body: unknown) =>
request<T>(config, path, { method: "POST", body }),
put: <T>(path: string, body: unknown) =>
request<T>(config, path, { method: "PUT", body }),
delete: <T>(path: string) =>
request<T>(config, path, { method: "DELETE" }),
};
}
// Usage
const ghlClient = createApiClient({
baseUrl: "https://rest.gohighlevel.com/v1",
defaultHeaders: { Authorization: `Bearer ${process.env.GHL_API_KEY}` },
timeout: 10_000,
});
const contacts = await ghlClient.get<{ contacts: Contact[] }>("/contacts", {
locationId: "loc_xxx",
limit: 100,
});
Rate Limiting (TypeScript)
class TokenBucketRateLimiter {
private tokens: number;
private lastRefill: number;
constructor(
private readonly capacity: number,
private readonly refillRate: number // tokens per second
) {
this.tokens = capacity;
this.lastRefill = Date.now();
}
async acquire(): Promise<void> {
const now = Date.now();
const elapsed = (now - this.lastRefill) / 1000;
this.tokens = Math.min(this.capacity, this.tokens + elapsed * this.refillRate);
this.lastRefill = now;
if (this.tokens >= 1) {
this.tokens -= 1;
return;
}
const waitMs = (1 - this.tokens) / this.refillRate * 1000;
await new Promise(resolve => setTimeout(resolve, waitMs));
this.tokens = 0;
}
}
const limiter = new TokenBucketRateLimiter(10, 2); // 10 burst, 2/sec sustained
async function rateLimitedGet<T>(url: string): Promise<T> {
await limiter.acquire();
return fetch(url).then(r => r.json() as Promise<T>);
}
Retry with Exponential Backoff
async function withRetry<T>(
fn: () => Promise<T>,
maxAttempts = 3,
baseDelayMs = 1000
): Promise<T> {
let lastError: Error;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error as Error;
if (error instanceof ApiError && error.status === 429) {
const delay = baseDelayMs * Math.pow(2, attempt);
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}
// Don't retry client errors
if (error instanceof ApiError && error.status < 500) throw error;
// Retry server errors
if (attempt < maxAttempts - 1) {
const delay = baseDelayMs * Math.pow(2, attempt);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
throw lastError!;
}
Pagination Helper
async function* paginate<T extends { items: unknown[]; nextCursor?: string }>(
fetcher: (cursor?: string) => Promise<T>
): AsyncGenerator<T["items"][number]> {
let cursor: string | undefined;
do {
const page = await fetcher(cursor);
yield* page.items;
cursor = page.nextCursor;
} while (cursor);
}
// Usage
for await (const contact of paginate((cursor) =>
ghlClient.get("/contacts", { cursor, limit: 100 })
)) {
console.log(contact);
}
Zod Validation for API Responses
import { z } from "zod";
const ContactSchema = z.object({
id: z.string(),
email: z.string().email(),
firstName: z.string().optional(),
lastName: z.string().optional(),
phone: z.string().optional(),
createdAt: z.string().datetime(),
});
type Contact = z.infer<typeof ContactSchema>;
async function fetchContact(id: string): Promise<Contact> {
const raw = await ghlClient.get<unknown>(`/contacts/${id}`);
return ContactSchema.parse(raw); // throws ZodError if shape mismatch
}
API Key Management Pattern
// Always from environment — never hardcoded
const config = {
rapidApiKey: process.env.RAPIDAPI_KEY ?? (() => { throw new Error("RAPIDAPI_KEY missing"); })(),
dataforseoUser: process.env.DATAFORSEO_LOGIN ?? (() => { throw new Error("DATAFORSEO_LOGIN missing"); })(),
};
At startup, validate all required keys are present before accepting requests.